Bu yazıda genel amaçlı bir e-posta gönderim sisteminin teknolojiden bağımsız tasarımını anlatacağım. E-postanın ne olduğu (OTP, bilgilendirme, rapor bildirimi…) bu sistemin derdi değil; o, tetikleyen tarafın business’ı. Bu sistemin işi şu:

“Şu alıcılara, şu şablonla, şu parametrelerle bir e-posta gönder” isteğini kabul etmek ve bunu güvenilir biçimde yerine getirmek.

Java, Node, Python, Go, .NET — hangi stack olursa olsun aynı prensipler geçerli. En sonda bir eşleştirme tablosu var; oradan kendi diline çevirebilirsin.

1. Problemin çerçevesi: üç gerçek

  1. Gönderim bir dış bağımlılıktır

    E-posta, senin kontrolünde olmayan bir sağlayıcı üzerinden çıkar (SMTP ya da API tabanlı servis: SES, SendGrid, Mailgun…). Sağlayıcı yavaşlayabilir, hata dönebilir. Sistem bunu absorbe edebilmeli; e-posta göndermek isteyen iş akışı bundan etkilenmemeli, bekletilmemeli.

    Bu yüzden gönderim asenkron olmalı: üretici kaydı bırakır, yoluna devam eder.

  2. Gönderim geri alınamaz bir yan etkidir

    Gönderilmiş e-postayı geri çekemezsin; mükerrer e-posta da kullanıcıyı rahatsız eder. Bu yüzden “aynı iş iki kez çalışsa da zararı yok” varsayımı burada geçerli değil. Aynı kaydın iki worker tarafından gönderilmesini engellemek, tasarımın merkezindeki problemlerden biri (claim ve tehlikeli pencere).

  3. Sağlayıcı hata verebilir

    Sistem ilk hatada pes etmemeli, ama sonsuza dek de denememeli. Geçici hatalar retry ile aşılır; retry’ın da bir tavanı ve ritmi olmalı (retry).

2. Genel akış

graph TD
    W["Herhangi bir iş akışı<br/>login · sipariş · rapor · alarm"]
    W -->|"kendi domain işlemiyle<br/>AYNI transaction içinde<br/>INSERT status = PENDING"| T[("email_messages<br/>hem iş kuyruğu<br/>hem gönderim kaydı")]
    T -->|"Model A: DB-poll<br/>Model B: relay → broker"| K["Gönderim worker'ı"]
    K --> C["1 · claim: PENDING → SENDING"]
    C --> R["2 · şablonu şimdi render et"]
    R --> S["3 · sağlayıcıya gönder"]
    S --> D["4 · SENDING → SENT"]
    S -.->|hata| RT["retry politikası"]
    T -.->|periyodik| CL["Temizlik scheduler'ı"]

Burada önemli olan şu: üretici hiç beklemiyor. E-posta göndermek isteyen kod bir satır INSERT edip işine dönüyor. Sağlayıcının o anki durumu üreticinin akışını etkilemiyor.

3. Veri modeli

EmailMessage
  id
  to              -- alıcı(lar); enqueue anında SNAPSHOT alınır: kullanıcı adresini
                  --  sonra değiştirse bile kayıt "nereye gönderdik"i söyleyebilmeli
  cc              -- opsiyonel liste
  bcc             -- opsiyonel liste
  template        -- şablon kimliği (örn. ORDER_CONFIRMED, PASSWORD_RESET)
  parameters      -- şablonun doldurulacağı değerler (isim, kod, sipariş no...)
                  --  render EDİLMEMİŞ
  attachments     -- ek listesi: [{filename, content_type, storage_ref}]
                  --  dosyanın KENDİSİ değil, referansı
  status          -- PENDING | SENDING | SENT | FAILED
  attempt_count   -- retry sayacı
  error_message   -- son hatanın kısa kaydı (teşhis için)
  locked_at       -- worker'ın işi üstlendiği an (stuck-job tespiti)
  created_at
  sent_at

Index önerileri:

IndexNe için
(status, created_at)poll sorgusu — FIFO iş çekimi
(status, locked_at)reaper taraması
created_atretention temizliği

Dört durum yeterli: PENDING (sırada), SENDING (bir worker üstlendi), SENT (sağlayıcı kabul etti), FAILED (denemeler tükendi). “Teslim edildi / geri sekti” gibi sonradan gelen akıbet bilgisini bilinçli olarak kapsam dışında tuttum.

Neden tek bir `payload` alanı değil de `template` + `parameters`?

Tek bir payload alanı iki işi birden yapmaya çalışır: hem içeriği taşımak hem de “bu ne e-postasıydı” sorusuna cevap vermek. İkiye ayırmak daha net:

  • template — hangi içerik. Aynı zamanda etiket görevi görür; log ve metrik filtrelemesi için tek başına yeterlidir. Ayrı bir type kolonuna gerek kalmaz.
  • parameters — o içeriğin değişkenleri.

Bu ayrım aynı zamanda render’ı gönderim anına ertelemenin ön koşulu.

Attachment'lar satıra blob olarak konmaz

Dosyanın kendisi satıra yazılırsa satırlar KB’larca/MB’larca şişer; tablo ve poll performansı bundan etkilenir. Satırda yalnızca referans durur (object storage path’i, dosya sistemi yolu vb.); worker gönderim anında dosyayı oradan okur.

İki pratik kural:

  1. Referansın gösterdiği dosya, gönderim gerçekleşene kadar değişmemeli ve silinmemeli. Kaynak dosya oynaksa, enqueue anında sistemin kendi sahiplendiği bir konuma kopyalanıp o kopya referanslanmalı.
  2. Retention kayıtları silerken bu dosyaları da temizlemeli; yoksa “tabloda kayıt yok ama storage’da yetim dosya var” durumu oluşur.

4. Atomik enqueue: kayıt, tetikleyen işlemle birlikte doğmalı

E-postayı tetikleyen domain işlemi (siparişi kaydetmek, OTP’yi üretmek) ile email_messages satırının INSERT’ü aynı transaction içinde olmalı. Aksi halde iki kötü senaryo var:

Önce domain, sonra e-posta kaydı

Arada çökme olursa sipariş var ama e-posta kaydı yok; kullanıcı hiç haber almaz.

Bu tür “sessiz kayıp"ın fark edilmesi zordur: ortada bir hata mesajı yok, sadece gelmeyen bir e-posta var.

Önce e-posta kaydı, sonra domain

E-posta gider ama bahsettiği şey henüz — belki de hiç — yoktur.

Kullanıcı var olmayan bir siparişin onayını alır.

E-posta tablosu domain verisiyle aynı veritabanında yaşadığı sürece bu garanti için ekstra bir şey yapmak gerekmiyor: tek transaction, iki yazma. Literatürdeki adıyla outbox pattern — burada tablo zaten doğal bir outbox.

Tetikleyici bir DB işlemi değilse tek satırlık INSERT kendi başına atomiktir; API “kabul edildi"yi satır commit olduğu an döner.

5. Gönderimi üstlenme: idempotent claim

İş alma modeli ne olursa olsun (poll da broker da) pratik garanti at-least-once: bir iş, worker çökmesi veya yeniden teslim gibi sebeplerle birden fazla worker’ın önüne düşebilir. İki worker aynı e-postayı gönderirse buraya kadarki uğraşın anlamı kalmaz.

Bu yüzden gönderime başlamadan önce atomik bir “üstlenme” adımı gerekiyor:

UPDATE email_messages
SET status = 'SENDING', locked_at = now()
WHERE id = :id AND status = 'PENDING'

-- 1 satır etkilendi: bu worker kazandı, devam
-- 0 satır etkilendi: başka biri üstlenmiş, sessizce çekil
UPDATE email_messages SET status = 'SENDING', locked_at = now()
WHERE id IN (
  SELECT id FROM email_messages
  WHERE status = 'PENDING'
  ORDER BY created_at
  LIMIT :N
  FOR UPDATE SKIP LOCKED    -- yarışan worker'lar birbirini beklemez
)
RETURNING *

Koşul ile güncelleme tek atomik adımda olduğundan (compare-and-swap), yarışan iki worker’dan yalnızca biri kazanır.

6. SENDING’de takılı kalanlar: stuck-job recovery

Yukarıdaki claim bir boşluk bırakıyor: worker işi üstlendikten sonra bitiremeden çökerse (OOM, deploy, node kaybı), kayıt SENDING’de kalır ve status = 'PENDING' arayan hiçbir claim onu bir daha almaz. Sonuç: takılı kalmış, gönderilmemiş bir e-posta.

Çözüm, “üstlenilmiş ama makul sürede bitmemiş iş, üstlenilmemiş sayılır” kuralı — mesajlaşma sistemlerindeki visibility timeout’un veritabanı karşılığı. Zamanlanmış bir reaper bu kayıtları periyodik olarak geri salıverir:

UPDATE email_messages
SET status = 'PENDING', locked_at = NULL
WHERE status = 'SENDING'
  AND locked_at < now() - INTERVAL 'X'

E-posta gönderimi kısa bir iş (bir API çağrısı), o yüzden X agresif seçilebilir — 1-2 dakika.

Reaper'ın iki tamamlayıcı kuralı

  • Sağlayıcı çağrısına mutlaka client timeout koy. Timeout’suz bir çağrı uzun süre askıda kalabilir; bu da reaper’ın “makul süre” tahminini geçersiz kılar ve iş hala çalışırken kaydın geri salınmasına yol açar.

  • Geri salınan işin tekrar gönderileceğini bil. Bu, bir sonraki bölümdeki pencereyle iç içe.

7. Tehlikeli pencere: “gönderdim ama SENT yazamadan öldüm”

sequenceDiagram
    participant W as Worker
    participant DB as email_messages
    participant P as Sağlayıcı
    W->>DB: claim → SENDING
    W->>P: gönder
    P-->>W: kabul edildi
    Note over W,DB: worker tam burada çökerse:<br/>e-posta GİTTİ ama kayıt SENDING'de kaldı
    W->>DB: status = SENT

SENDING’de takılı bir kayda bakan reaper şunu bilemez: çökme çağrıdan önce miydi (e-posta gitmedi, tekrar denenmeli), sonra mıydı (gitti, tekrar denersen mükerrer)?

Dış dünyaya yan etki ile kendi kaydını tek atomik adımda yapamazsın; exactly-once burada mümkün değil. Seçim, at-least-once (nadir mükerrer riski) ile at-most-once (nadir kayıp riski) arasında.

Pragmatik varsayılan: at-least-once’ı kabul et. Reaper geri salar, iş tekrar gönderilir; çok nadir bir çökme senaryosunda kullanıcı aynı e-postayı iki kez alabilir.

Çoğu e-posta için mükerrerlik, kayıptan daha az kötü: iki kez gelen onay tuhaf, hiç gelmeyen e-posta arıza. Önemli olan bu kararın farkında olarak verilmesi.

Riski küçültmek de mümkün:

  • Bazı sağlayıcı API’leri idempotency token kabul eder — aynı token’la gelen ikinci isteği kendisi yutar. Token olarak kaydın id‘si geçilir.
  • Pencere mekanik olarak daraltılır: çağrı biter bitmez, araya başka iş sokulmadan SENT yazılır.

Ama pencere tamamen kapanmaz; amaç riski sıfırlamak değil, yönetmek.

8. Retry: backoff + max attempt

Sağlayıcıdan hata alınırsa iş hemen FAILED olmaz; ama sonsuza dek de denenmez.

  • Sayaç ve tavan. Her başarısız denemede attempt_count artar; max_attempts‘e (tipik 3–5) ulaşınca iş FAILED olur ve error_message‘a son hata yazılır. FAILED silinmez; teşhis edilebilir halde durur ve izlenir.
  • Backoff + jitter. Denemeler arası bekleme artan olmalı (örn. 30 sn → 2 dk → 10 dk) ve üzerine rastgelelik eklenmeli. Yoksa sağlayıcının geçici bir arızasında tüm retry’lar aynı anda üşüşüp arızayı uzatır.
  • Kaba bir hata ayrımı yeterli.
HataSınıfDavranış
timeout, 5xx, bağlantı hatasıgeçiciretry mantıklı
4xx — geçersiz adres, bozuk istek, yetkikalıcıilk denemede FAILED

İnce sınıflandırma (bounce türleri vb.) şimdilik gerekmiyor; “her hatayı 5 kez dene” tembelliğinden kaçınmak yeterli.

Uygulama notu: retry zamanlaması için ya locked_at / ayrı bir next_attempt_at kolonu kullanılır ya da — daha kolayı — kullanılan job kütüphanesinin hazır retry mekanizmasına yaslanılır. Çoğu bunu kutudan verir.

9. Şablon render’ı: gönderim anında

Satıra render edilmiş HTML değil, template + parameters yazılır; şablon gönderim anında doldurulur. İki sebebi var:

  • Şablondaki bir hata, kuyrukta bekleyen işleri “bozuk içerikle donmuş” hale getirmez. Şablon düzeltilir, bekleyenler düzgün gider.
  • Satırlar küçük kalır. HTML kilobaytlar tutar, parametre birkaç yüz byte; tablo sağlığı ve poll performansı bundan doğrudan etkilenir.

Render hatası (eksik parametre, var olmayan şablon) kalıcı hata sınıfından: retry düzeltmez, doğrudan FAILED.

10. Polling mi, broker mı?

İki model var:

DB-poll (pull)

Worker’lar tabloyu periyodik sorgular, FOR UPDATE SKIP LOCKED deseniyle iş çeker.

Ek altyapı yok; kuyruk, tablonun kendisi.

Broker (push)

Bir relay, tablodaki yeni işleri broker’a mesaj olarak basar; broker bunları consumer’lara kendisi iter.

Gecikme ms mertebesine iner, consumer prefetch ile kapasitesini bildirir.

E-posta özelinde çoğu durumda DB-poll daha mantıklı geliyor bana. Sebepleri:

  • Tablo her halükarda şart. Gönderim kaydı, retry sayacı, teşhis bilgisi — hepsi kalıcı kayıt istiyor. Broker tabloyu ikame etmiyor, üstüne ekleniyor. Gerçek soru “tablo mu broker mı” değil, “tablonun üstüne broker eklemeye değer mi”.
  • Atomik enqueue kendiliğinden geliyor. Aynı-DB modelinde §4’ün garantisi tek transaction; broker’lı modelde araya relay giriyor (tablo → relay → broker). Çalışır, ama işletilecek ek bir parça.
  • Gecikme toleransı yeterli. Poll gecikmesi ortalama poll aralığının yarısı; birkaç saniyelik aralık e-posta için genelde yeterli. Daha hızlısı gerekirse bile broker tek yol değil: Postgres LISTEN/NOTIFY gibi mekanizmalar poll’u fiilen push’a çevirir.

Broker’ın gerçekten kazandığı yer: e-posta gönderimi birçok bağımsız servise hizmet eden ortak bir platform servisine dönüştüğünde. “Herkes aynı tabloya yazsın” demek, servisleri veritabanı üzerinden birbirine bağlamak demek — bu bir anti-pattern; ortak protokol (AMQP ya da servisin kendi API’si) doğru sınır.

Bir de yüksek hacimde DLQ, delayed retry gibi broker-native parçaların hazır gelmesi değer kazanıyor. Tek uygulama — ya da aynı veritabanını paylaşan az sayıda servis — profilinde bunların pek karşılığı yok.

KriterDB-pollBroker
GecikmePoll aralığına bağlı (sn) — LISTEN/NOTIFY ile msNative push (ms)
Atomik enqueueTek transaction, ek iş yokRelay kurulmalı
Ek altyapıYokBroker + relay işletimi
Çok üretici servisOrtak DB’ye bağlar (anti-pattern)Doğru sınır
Uygun profilTek uygulama / aynı DB, orta hacimOrtak platform servisi, yüksek hacim

11. Retention: eski kayıtları temizleyen scheduler

email_messages hem kuyruk hem kayıt defteri olduğu için sürekli büyür; sınırsız büyüme tabloyu, index’leri ve poll sorgusunu zamanla ağırlaştırır. Çözüm basit bir zamanlanmış temizlik işi:

-- örn. günde bir kez, sakin saatte:
DELETE FROM email_messages
WHERE created_at < now() - INTERVAL 'N days'
  AND status IN ('SENT', 'FAILED')
LIMIT :batch_size  -- büyük tek DELETE yerine parça parça (lock/WAL baskısı yapmasın)

Karar noktaları:

  • N kaç gün? “Ne zaman ne gönderdik” sorusuna ne kadar geriye dönük cevap vermek istiyorsan o kadar. Destek ve denetim ihtiyacına göre tipik olarak 30–90 gün.
  • Yalnızca uçtaki durumlar silinir — SENT ve FAILED. PENDING/SENDING silinmez; onlar hala işlenecek ya da işlenen işler. Çok eski bir PENDING varsa bu retention’ın değil, bir arızanın konusudur.
  • Attachment dosyaları da temizlenmeli. Kayıt silinirken attachments‘ın referansladığı, sistemin sahiplendiği dosyalar da silinmeli; yoksa storage’da yetim dosyalar birikir.
  • Sil vs arşivle. Kayıtları tamamen kaybetmek istemiyorsan silmek yerine ucuz bir arşiv tablosuna/depoya taşı; mantık aynı, hedef farklı.

Aynı scheduler, reaper ile birlikte sistemin iki bakım görevlisi: biri takılanı kurtarır, öteki eskiyeni temizler.

12. Şimdilik bilinçli olarak kapsam dışı

Aşağıdakileri bu tasarıma eklemedim. İhtiyaç doğduğunda eklenebilir olduklarını bilmek yeterli; mevcut şema hiçbirine engel değil.

Enqueue tekilleştirmesi (dedup key)

Bu sistemdeki korumalar (claim, tehlikeli pencere) “var olan bir kaydın iki kez gönderilmesini” engeller; “aynı olay için iki ayrı kaydın oluşturulmasını” engellemez.

İkincisi ancak tetikleyen taraf aynı olayı iki kez işlerse olur — kendi retry mekanizması, çift tıklama vb. Tetikleyiciler basit ve tek atımlıksa bu risk pratikte yok.

Gün gelir de bir tetikleyici aynı olayı yanlışlıkla iki kez INSERT etmeye başlarsa, çözümün iskeleti basit: olay kimliğini taşıyan bir kolon + UNIQUE index. Anahtarı tetikleyen üretir.

Teslim takibi / webhook'lar

SENT = sağlayıcı kabul etti. Gerçekte teslim edildi mi, geri mi sekti (bounce) bilgisi sağlayıcıdan webhook ile sonradan gelir. Şu an gerekmiyorsa kurulmasına gerek yok.

İleride eklenirse iskelet şu kadar: sağlayıcının verdiği message-id’yi kaydetmek (provider_message_id kolonu) ve webhook işlemeyi idempotent + imza doğrulamalı yapmak.

Throttle / sağlayıcı rate limit'i

Hacim büyüdüğünde sağlayıcının gönderim limitine takılmamak için hız tavanı gerekebilir. O gün geldiğinde en basit hali zaten elde: poll’daki LIMIT × worker sayısı ÷ aralık, doğal bir tavan.

Geçerlilik süresi (TTL), öncelik, unsubscribe
  • TTL: “Süresi geçmiş e-postayı hiç gönderme” davranışı expires_at kolonu + claim sonrası tek bir kontrolle eklenebilir. Sistem güncel kaldığı sürece (kuyruk birikmiyorsa) gerekmez.

  • Öncelik ayrımı: Belirli şablonların öne geçmesi gerekirse ORDER BY‘a bir öncelik kolonu eklemek ya da ayrı worker havuzu kurmak yeterli.

  • Tercih/unsubscribe ve suppression list: Pazarlama benzeri gönderimler devreye girerse gönderim anında yapılacak kontroller; işlemsel e-postalar için gerekmez.

13. Checklist

14. Eşleştirme tablosu

KavramJava/SpringNode.jsPython.NET
DB-tabanlı worker + retryJobRunrpg-boss (Postgres) / BullMQ (Redis)Procrastinate (Postgres) / CeleryHangfire
Claim + stuck recoveryJobRunr’da hazırpg-boss/BullMQ’da hazırKütüphanede hazırHangfire’da hazır
Sağlayıcı istemcisiSES SDK / JavaMailnodemailer / SDK’larboto3 / httpxAWS SDK / SmtpClient
Şablon motoruThymeleaf / FreemarkerHandlebarsJinja2Razor
Retention scheduler’ıSpring @Scheduled / JobRunr recurringnode-cron / pg-boss scheduleCelery beat / cronHangfire recurring

pg-boss ve Procrastinate gibi Postgres tabanlı kuyruklar bu mimariye iyi oturuyor: iş kaydı domain verisiyle aynı veritabanında (atomiklik garantisi için ek iş çıkmıyor), claim/reaper/retry kutudan geliyor ve LISTEN/NOTIFY sayesinde poll gecikmesi fiilen push seviyesinde.