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
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.
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).
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:
| Index | Ne için |
|---|---|
(status, created_at) | poll sorgusu — FIFO iş çekimi |
(status, locked_at) | reaper taraması |
created_at | retention 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ı birtypekolonuna 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:
- 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ı.
- 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 = SENTSENDING’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_countartar;max_attempts‘e (tipik 3–5) ulaşınca işFAILEDolur veerror_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.
| Hata | Sınıf | Davranış |
|---|---|---|
| timeout, 5xx, bağlantı hatası | geçici | retry mantıklı |
| 4xx — geçersiz adres, bozuk istek, yetki | kalı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/NOTIFYgibi 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.
| Kriter | DB-poll | Broker |
|---|---|---|
| Gecikme | Poll aralığına bağlı (sn) — LISTEN/NOTIFY ile ms | Native push (ms) |
| Atomik enqueue | Tek transaction, ek iş yok | Relay kurulmalı |
| Ek altyapı | Yok | Broker + relay işletimi |
| Çok üretici servis | Ortak DB’ye bağlar (anti-pattern) | Doğru sınır |
| Uygun profil | Tek uygulama / aynı DB, orta hacim | Ortak 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_atkolonu + 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
- Üretici beklemiyor: e-posta isteği = tek INSERT, gönderim asenkron
- E-posta kaydı, tetikleyen domain işlemiyle aynı transaction’da doğuyor
- Alıcılar (to/cc/bcc) enqueue anında snapshot’lanıyor
- Attachment’lar satırda referans olarak duruyor, blob olarak değil
- Gönderim öncesi atomik claim — PENDING → SENDING, koşullu UPDATE
- SENDING’de takılanlar için reaper + sağlayıcı çağrısında client timeout
- “Gönderdim ama kaydedemedim” penceresi için bilinçli karar: at-least-once kabulü, varsa sağlayıcı idempotency token’ı
- Retry: max attempt + backoff/jitter; 4xx’te anında FAILED
- Şablon gönderim anında render ediliyor; render hatası kalıcı hata
- Poll-vs-broker kararı profile göre verildi — tek uygulama → DB-poll
- Retention scheduler’ı var: eski SENT/FAILED kayıtlar ve attachment dosyaları parça parça temizleniyor
- Temel metrikler: en eski PENDING’in yaşı, FAILED oranı, gönderim süresi
14. Eşleştirme tablosu
| Kavram | Java/Spring | Node.js | Python | .NET |
|---|---|---|---|---|
| DB-tabanlı worker + retry | JobRunr | pg-boss (Postgres) / BullMQ (Redis) | Procrastinate (Postgres) / Celery | Hangfire |
| Claim + stuck recovery | JobRunr’da hazır | pg-boss/BullMQ’da hazır | Kütüphanede hazır | Hangfire’da hazır |
| Sağlayıcı istemcisi | SES SDK / JavaMail | nodemailer / SDK’lar | boto3 / httpx | AWS SDK / SmtpClient |
| Şablon motoru | Thymeleaf / Freemarker | Handlebars | Jinja2 | Razor |
| Retention scheduler’ı | Spring @Scheduled / JobRunr recurring | node-cron / pg-boss schedule | Celery beat / cron | Hangfire 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.

Yorumlar