Bir kutuya düşen mailleri otomatik işleyen bir servis kuruyorsun: gelen faturaları muhasebeye, başvuru maillerini bir kuyruğa, sipariş bildirimlerini bir webhook'a çeviriyorsun. En saf hali üç satır:
1. UNSEEN olanları çek
2. Ayrıştır (parse)
3. İşi yap, \Seen'e işaretleBu üç satır demoda kusursuz çalışır. Üretimde tam olarak üç yerden patlar: \Seen bayrağına güvenmek, UIDVALIDITY'yi yok saymak ve MIME gövdesini string olarak kesmeye çalışmak. Bağlanmak zaten kolay — asıl mühendislik durum takibi ve ayrıştırma. Bu yazı o üç patlamayı sırayla kapatıp, gece 03:00'te kimse uyanmadan çalışan idempotent bir tüketici kuruyor. Node.js (imapflow + mailparser) ve Python (IMAPClient + email) tarafını yan yana veriyorum.
IMAP gerçekte nasıl bir veri modeli
Bir mailbox'ı SELECT ettiğinde sunucu sana üç kritik sayı döner: UIDVALIDITY, UIDNEXT ve EXISTS. Buradaki en yaygın hata, mesajlara sequence number ile atıfta bulunmaktır. Sequence number oturum içinde kayar: 5 numaralı mesaj silinince eski 6 numara 5 olur. Bir sonraki turda "5'ten sonrasını al" dersen yanlış maili işlersin.
UID ise kalıcıdır — ta ki UIDVALIDITY değişene kadar. Otomasyonun tek doğru çapası UID'dir. Mantık şu: en son gördüğün UID'i kalıcı olarak sakla, her turda UID SEARCH UID last_uid+1:* ile yalnızca yenileri çek. Bütün kutuyu tarama, EXISTS sayısını sayma; sadece deltayı al.
UIDVALIDITY değişirse — kutu silinip yeniden oluşturulmuş, taşınmış ya da sunucu tarafında yeniden indekslenmişse — elindeki tüm UID cache'i çöp olur. Eski last_uid=5000, yeni dünyada bambaşka bir maile denk gelir. Kural net: her tur başında sakladığın uidvalidity ile sunucunun döndürdüğünü karşılaştır; eşleşmiyorsa cache'i sıfırla, last_uid=0 yap ve full resync'e gir.
\Seen bayrağına gelince: bu bayrak idempotency için felakettir. Kullanıcı telefonundaki mail uygulamasıyla kutuya bakarsa maili "okundu" yapar; senin işleyicin onu "işlenmiş" sanıp atlar. Ya da tam tersi. \Seen insan tüketiminin işaretçisidir, senin değil. Bunun yerine kendi özel keyword'ünü kullan: $Processed. Ve okuma yaparken mutlaka BODY.PEEK[] kullan — BODY[] maili \Seen yapar, BODY.PEEK[] yapmaz.
Bağlantı ve kimlik doğrulama
Üretimde daima 993/TLS (implicit TLS). 143 + STARTTLS hâlâ RFC'de var ama downgrade saldırı yüzeyi ve yanlış yapılandırma riski taşır; yeni bir servis kuruyorsan 143'e hiç bakma.
Bir şey ters gittiğinde en hızlı teşhis, protokolü elle konuşmaktır:
openssl s_client -connect imap.gmail.com:993 -crlf
# bağlantı açılınca:
a LOGIN kullanici parola
a SELECT INBOX
# → * OK [UIDVALIDITY 1712345678] ...
# → * OK [UIDNEXT 5001] ...
a UID SEARCH UID 5000:*
# → * SEARCH 5000 5001 5002
a UID FETCH 5001 (BODY.PEEK[] FLAGS)Bu çıktı, kütüphanenin arkasında ne olduğunu birebir gösterir; bir hatayı ayıklarken kütüphane katmanını atlayıp doğrudan buraya inmek çoğu zaman en kısa yoldur.
Node.js tarafında imapflow ile bağlantı ve mailbox açma:
import { ImapFlow } from 'imapflow';
const client = new ImapFlow({
host: 'mail.evilmail.pro',
port: 993,
secure: true,
auth: { user: '[email protected]', pass: process.env.IMAP_PASS },
logger: false,
});
await client.connect();
const box = await client.mailboxOpen('INBOX');
// box.uidValidity (BigInt), box.uidNext, box.existsPython tarafında IMAPClient:
from imapclient import IMAPClient
with IMAPClient('mail.evilmail.pro', port=993, ssl=True) as server:
server.login('[email protected]', os.environ['IMAP_PASS'])
status = server.folder_status('INBOX', ['UIDVALIDITY', 'UIDNEXT'])
server.select_folder('INBOX')
# status[b'UIDVALIDITY'], status[b'UIDNEXT']Sağlayıcı gerçeği (2026): Gmail'de "less secure app" erişimi kapatıldı; app password hâlâ mümkün ama yalnızca 2FA açıksa ve Workspace yöneticisi izin veriyorsa. Ciddi otomasyonda XOAUTH2 fiili standart: access token 1 saatte biter, refresh token ile yenileme sorumluluğu tamamen sende. Microsoft 365'te temel kimlik doğrulama (basic auth) kapatıldı, IMAP için OAuth2 mecburi. Ama evilmail.pro gibi kendi Dovecot sunucunda düz kullanıcı/parola + 993/TLS hâlâ tümüyle geçerli — OAuth dansına girmeden robot hesabı açıp doğrudan bağlanırsın. Otomasyon kutusu için kendi altyapını kullanmanın en somut avantajı budur.
Yeni mesajları güvenilir yakalama
Kalıcı durumun tamamı üç alandan ibaret: {mailbox, uidvalidity, last_uid}. Bunu Redis, SQLite ya da Postgres'te tut — nerede tuttuğun önemli değil, kaybolmaması önemli. Akış her turda şöyle işler:
- 1.
SELECT INBOX→ sunucununUIDVALIDITY'sini oku. - 2.Sakladığın
uidvalidityile karşılaştır. Eşleşmiyorsa:last_uid = 0, cache temizle (full resync). - 3.
UID SEARCH UID {last_uid+1}:*ile yeni UID listesini al. - 4.Her mesajı
BODY.PEEK[]ile çek, işle. - 5.İş başarıyla bittikten sonra
UID STORE {uid} +FLAGS ($Processed)yaz velast_uid'i ilerlet.
Bu sıralama bir at-least-once garantisi kurar. Kritik nokta: durumu işten *sonra* ilerletmek. İş yarıda patlarsa hiçbir şey yazma; bir sonraki tur aynı UID'i tekrar getirir ve tekrar dener. Bu yüzden işleyicinin kendisi idempotent olmalı — Message-ID dedup'a birazdan geliyoruz.
Bayrak değişimlerini de takip etmen gerekiyorsa — örneğin bir insan bir maili \Answered yaptığında tetiklenmek istiyorsan — RFC 7162 CONDSTORE/QRESYNC devreye girer. MODSEQ ile "şu MODSEQ'ten beri değişen bayraklar" sorgulanır ve tüm kutuyu taramadan flag delta'sı alınır. Çoğu "gelen maili işle" senaryosunda buna ihtiyacın yok; ama flag senkronizasyonu gerekiyorsa tek verimli yol budur.
MIME'ı doğru ayrıştırma — asıl acı burada
Ham mesajı (RFC 5322) elle string olarak kesmek en pahalı hatadır. Header'lar 78 karakterde katlanır (folding), Subject bir encoded-word olabilir (=?UTF-8?Q?Fatura_=23129?=), gövde iç içe geçmiş multipart'lardan oluşur. body.split('\n\n')[1] gibi bir şey yazan herkes eninde sonunda bir faturanın tutarını kaçırır.
Gerçek bir mailin yapısı bir ağaçtır:
Node.js'te bu ağacı elle gezmene gerek yok — mailparser'ın simpleParser'ı tüm işi yapar, encoded-word'leri çözer, charset'i normalize eder:
import { simpleParser } from 'mailparser';
const lock = await client.getMailboxLock('INBOX');
try {
for await (const msg of client.fetch(`${lastUid + 1}:*`,
{ uid: true, source: true, flags: true })) {
const mail = await simpleParser(msg.source);
// mail.subject, mail.text, mail.html, mail.messageId
// mail.attachments: [{ filename, contentType, content: Buffer, size }]
await handle(mail, msg.uid);
}
} finally {
lock.release();
}Python'da stdlib email ile ağacı walk() ederek gezersin. Charset burada Türkçe için hayati: yanlış charset varsayımı ç/ş/ğ/ı/İ karakterlerini bozar. Doğru yol, parçanın kendi charset'ini okuyup errors='replace' ile decode etmek:
import email
from email.header import decode_header, make_header
raw = server.fetch(uids, ['BODY.PEEK[]'])
for uid, data in raw.items():
m = email.message_from_bytes(data[b'BODY[]'])
subject = str(make_header(decode_header(m['Subject'])))
message_id = m['Message-ID']
for part in m.walk():
ctype = part.get_content_type()
if ctype == 'text/plain' and not part.get_filename():
cs = part.get_content_charset() or 'utf-8'
body = part.get_payload(decode=True).decode(cs, errors='replace')
elif part.get_filename():
save_attachment(part) # aşağıdaEk yazarken üç şeye dikkat et: filename'i decode_header ile çöz, os.path.basename uygula ve bir whitelist ile path traversal'ı engelle, bir de üst boyut sınırı koy (örneğin 25 MB). Content-Disposition: attachment; filename="../../etc/cron.d/x" gönderen biri, kontrol etmezsen sunucuna dosya yazar.
import os
def save_attachment(part, out_dir='/var/spool/robot', max_bytes=25 * 1024 * 1024):
name = str(make_header(decode_header(part.get_filename())))
name = os.path.basename(name) # path traversal kes
payload = part.get_payload(decode=True)
if payload is None or len(payload) > max_bytes:
return
path = os.path.join(out_dir, name)
with open(path, 'wb') as f:
f.write(payload)Dedup: neden hem bayrak hem tablo
$Processed bayrağı tek başına yeterli değil, çünkü bayraklar oynak. Mail başka bir klasöre taşınırsa, kutu yeniden indekslenirse ya da bir insan yanlışlıkla bayrağı temizlerse aynı maili tekrar işlersin. Bu yüzden ikinci bir savunma hattı: her mailin Message-ID header'ını kalıcı bir processed_message_ids tablosunda tut. İşleyiciye girmeden önce bu tabloya bak; varsa atla. Bayrak "sunucu tarafı hızlı işaret", tablo "senin tarafında kesin gerçek". At-least-once teslimatı bir veri olarak kabul et ve işi iki kez çalışsa da zarar vermeyecek şekilde yaz: upsert, unique constraint, idempotency key.
Gerçek zamanlı: IDLE ve reconnect döngüsü
Polling ucuz ama gecikmeli. Anlık tepki istiyorsan RFC 2177 IDLE kullanırsın: IDLE komutunu gönderirsin, sunucu yeni mail geldiğinde * EXISTS push eder. Buradaki tuzak: sunucuların büyük çoğunluğu bir IDLE bağlantısını 29 dakikada sessizce düşürür. Sen fark etmezsin — soket açık görünür ama artık ölüdür. Kural: yaklaşık 28 dakikada bir DONE gönderip IDLE'ı yeniden kur. imapflow bunu içeride yönetir ama reconnect mantığını yine de sen yazmalısın.
Sağlam bir model IDLE + fallback polling hibrididir: IDLE dinlerken bir yandan 5 dakikada bir güvenlik amaçlı poll at. NAT/proxy arkasındaysan TCP keepalive'ı da aç. Bağlantı düştüğünde exponential backoff + jitter ile yeniden bağlan (1s, 2s, 4s… max 60s; jitter olmadan tüm worker'lar aynı anda vurur). Ve her reconnect'te SELECT sonrası UIDVALIDITY'yi tekrar doğrula — bağlantı koptuğu sürede kutu yeniden oluşturulmuş olabilir.
async function run() {
for (;;) {
try {
await client.connect();
const box = await client.mailboxOpen('INBOX');
await resyncIfNeeded(box.uidValidity); // UIDVALIDITY kontrolü
await drainNewMessages(); // last_uid+1:*
client.on('exists', () => drainNewMessages());
await client.idle(); // ~28dk'da otomatik yenilenir
} catch (err) {
await sleep(backoff() + jitter()); // exp backoff + jitter
} finally {
backoffReset();
}
}
}Üretim sertleştirme ve sınırlar
- Sağlayıcı kotaları: Gmail'de IMAP indirmesi hesap başına günde ~2500 MB, aynı hesaba eşzamanlı bağlantı ~15 ile sınırlı. Bu sınırlara toslarsan
[OVERQUOTA]yersin; ekleri gerçekten indirmen gerekmiyorsaBODY.PEEK[HEADER]+BODYSTRUCTUREile sadece gerekeni çek. - Tek tüketici: İki worker aynı kutuyu işlerse
$Processedyarışı ve çift teslimat olur. Bir lider seçimi (Redis lock, advisory lock) ile kutu başına tek aktif tüketici garantile. - Gözlemlenebilirlik:
last_uidgecikmesini, işlenmemiş UID sayısını ve IDLE bağlantısının yaşını metrik olarak yayınla. IDLE yaşı 28 dakikayı geçiyorsa alarm çal. - Güvenlik: Kimlik bilgilerini env/secret store'da tut, asla repoda değil. Ekleri sandbox'ta işle. Ve log'a mail gövdesi yazma — bir gün o log'da birinin kişisel verisi olur.
Üretime çıkmadan hızlı kontrol listesi
UIDVALIDITY'yi her turda doğrula; değiştiyselast_uid=0full resync.- İdempotency işaretçisi olarak
\Seendeğil$Processedkullan. - Okumayı
BODY.PEEK[]ile yap;BODY[]maili okundu işaretler. - IDLE bağlantısını ~28 dakikada bir yeniden kur; fallback polling ekle.
Message-IDdedup tablosu tut; bayrak kaybolabilir.- Ekleri boyut sınırı +
basename+ whitelist ile yaz. - Gmail/M365 için XOAUTH2 refresh'i sen yönet; kendi Dovecot'unda düz LOGIN yeterli.
- Reconnect'te exponential backoff + jitter uygula, her seferinde
UIDVALIDITY
Kutuyu değil, durumunu yönet.


