CI/CD Hattında E-postaları Mailpit ile Yakalayıp Otomatik Test Etmek
Uygulamanızın gönderdiği e-postaları CI'da mock'lamak yerine gerçek ama sahte bir SMTP sunucusuyla yakalayın: bozuk MIME sınırları, yanlış encode edilmiş Türkçe başlıklar ve kaçak placeholder'lar prod'a gitmeden testte patlasın.
EvilMail Team3 Ağustos 202611 dk okuma
Staging'de doğrulama e-postası kusursuz görünüyordu. Konu satırı "Şifre sıfırlama talebiniz", HTML şablonu pırıl pırıl, buton tıklanabilir. Prod'a çıktı ve müşteri gelen kutusunda konuyu şöyle gördü: =?UTF-8?B?xZ5pZnJlIHPEsWbEsXJsYW1h?=. Bazı istemciler decode etti, Outlook'un eski bir sürümü etmedi. Bir haftalık kayıt akışı, decode edilmemiş tek bir başlık yüzünden spam gibi göründü.
Bu neden testlerden sızarak geçti? Çünkü test suite'i nodemailer'ın sendMail fonksiyonunu mock'lamıştı. Mock, "mailer çağrıldı mı" sorusuna cevap veriyordu ama transport katmanına hiç ulaşmıyordu. MIME encoder, quoted-printable dönüşümü, RFC 2047 başlık kodlaması, multipart sınır string'leri — bunların hiçbiri çalışmadı. Test yeşildi çünkü test hiçbir zaman telin ucundan çıkan gerçek zarfa bakmadı.
Çözüm mock'u iyileştirmek değil, atmak. CI hattına gerçek SMTP konuşan ama e-postayı dünyaya salmayan sahte bir kutu koyarsınız. Uygulama 127.0.0.1:1025'e gerçekten SMTP konuşur, encoder tam kapasite çalışır, mesaj yakalanır, siz de HTTP API üzerinden ne çıktığını byte byte assert edersiniz.
Jest mock'u sendMail'i keser; encoder'a hiç ulaşamazsınız. Sahte SMTP yaklaşımı bunun tam tersi: uygulama gerçek SMTP oturumu açar, sahte sunucu mesajı belleğe yazar, siz HTTP API'den okuyup doğrularsınız. Bu testte mailer config'i, template engine ve MIME encoder hep birlikte devreye girer — yani prod'da çalışacak olan tam zincir.
2026'da MailHog mı, Mailpit mi
Kısa cevap: yeni bir hat kuruyorsanız Mailpit. Uzun cevap bir karar tablosu.
MailHog (mailhog/MailHog) yıllarca standarttı: tek Go binary, SMTP 1025, web UI ve API 8025, JSON API v1/v2. Ama repo fiilen dondurulmuş durumda — son anlamlı sürüm 2020 civarı, açık PR'lar yıllardır bekliyor. HTML istemci uyumluluğu, link kontrolü, spam skoru gibi hiçbir modern doğrulama yok. Hâlâ mesaj yakalar, ama sadece "geldi mi" sorusuna cevap verir.
Mailpit (axllent/mailpit) MailHog'un ruhen halefi. Aynı default portları kullanır — SMTP 1025, UI+API 8025 — yani çoğu durumda drop-in geçiş. Tek statik Go binary, bellek veya SQLite depolama, aktif geliştirme. Üstüne MailHog'da olmayan cephanelik: gerçek arama sözdizimi, REST API v1, HTML uyumluluk kontrolü, link kontrolü, SpamAssassin entegrasyonu, POP3 ve SMTP hatalarını enjekte eden "chaos" modu.
Mevcut bir MailHog hattınız varsa göç yolu kısa: SMTP tarafı zaten aynı porta konuşuyor, değişen tek şey HTTP API path'leri. MailHog'un GET /api/v2/messages ve GET /api/v2/search?kind=to&query=... çağrıları, Mailpit'te GET /api/v1/messages ve GET /api/v1/search?query=to:... olur. Mesaj gövdesi de farklı: MailHog ham Content.Headers ve base64/quoted-printable gövde döner, Mailpit ise decode edilmiş Text/HTML alanları verir — bu, testlerinizi ciddi ölçüde sadeleştirir. Yazının geri kalanı Mailpit üzerinden ilerliyor, MailHog karşılıklarını gerektiğinde parantez içinde veriyorum.
Yerel kurulum: Mailpit'i ayağa kaldır, uygulamayı yönlendir
MP_SMTP_AUTH_ACCEPT_ANY ve MP_SMTP_AUTH_ALLOW_INSECURE bayrakları önemli: uygulamanız SMTP auth deniyorsa (prod'da genellikle dener), Mailpit varsayılan olarak TLS'siz auth'u reddeder. Bu iki bayrak, testte herhangi bir kullanıcı/parolayı kabul etmesini sağlar.
Uygulama tarafında transport, auth'suz ve TLS'siz localhost:1025'e bakar:
evilmail'de mailer (src/lib/mailer.ts) SMTP ayarlarını DB'den Redis cache ile okuyor. Test ortamında bu değerleri DB'ye dokunmadan env override ile 1025'e çeviriyoruz — SMTP_HOST=localhost SMTP_PORT=1025 SMTP_SECURE=false. Böylece prod kod yolu değişmeden, aynı sendTemplateEmail(key, to, vars) fonksiyonu Mailpit'e teslim ediyor. Tarayıcıdan http://localhost:8025 açıp gözle de doğrulayabilirsiniz; ama asıl mesele bunu otomatikleştirmek.
Mesajı API'den yakala ve assert et
Mailpit REST API'sinin test için ihtiyacınız olan yüzeyi dar ve öngörülebilir:
GET /api/v1/messages — liste, start/limit ile sayfalama.
GET /api/v1/message/{ID} — tam mesaj: From, To, Cc, Bcc, ReplyTo, Subject, Date, Text, HTML, Attachments[], Inline[].
GET /api/v1/message/{ID}/headers — ham başlık haritası (List-Unsubscribe, Reply-To gibi mesaj gövdesinde dönmeyen başlıklar burada).
GET /api/v1/search?query=... — to:, from:, subject:, is:unread ve tırnaklı ifadeler destekler.
DELETE /api/v1/messages — tüm kutuyu boşaltır; test izolasyonu için beforeEach'te çağırın.
Kritik bir nokta: SMTP teslimi asenkron. Uygulama sendMail'i çözdüğünde mesaj Mailpit'e ulaşmış olmayabilir. Sabit sleep koymak flaky test üretir — kısa aralıklı poll kullanın.
javascript
const MP = 'http://localhost:8025';
async function waitForMessage(query, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${MP}/api/v1/search?query=${encodeURIComponent(query)}`);
const { messages } = await res.json();
if (messages.length > 0) {
const full = await fetch(`${MP}/api/v1/message/${messages[0].ID}`);
return full.json();
}
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`Mesaj gelmedi: ${query}`);
}
beforeEach(async () => {
await fetch(`${MP}/api/v1/messages`, { method: 'DELETE' });
});
test('şifre sıfırlama e-postası doğru ve eksiksiz gider', async () => {
await request(app)
.post('/api/auth/forgot-password')
.send({ email: '[email protected]' });
const msg = await waitForMessage('to:[email protected] subject:"Şifre sıfırlama"');
expect(msg.To[0].Address).toBe('[email protected]');
// Konu ham =?UTF-8?...?= değil, decode edilmiş haliyle gelmeli
expect(msg.Subject).toBe('Şifre sıfırlama talebiniz');
// HTML gövdede gerçek reset linki var mı
expect(msg.HTML).toMatch(/https:\/\/evilmail\.pro\/reset\?token=[a-f0-9]{32}/);
// Kaçak placeholder kalmamış olmalı
expect(msg.HTML).not.toMatch(/\{\{/);
expect(msg.Text).not.toMatch(/\{\{/);
// Deliverability başlığı yerinde mi — başlıklar ayrı endpoint'ten gelir
const headers = await (await fetch(`${MP}/api/v1/message/${msg.ID}/headers`)).json();
expect(headers['List-Unsubscribe']).toBeDefined();
});
Buradaki her assertion, mock'un sessizce atladığı bir gerçek kırılma noktası. msg.Subject decode edilmiş geldiği için Türkçe konu bozuksa test kırmızıya döner — girişteki =?UTF-8?B?... faciası artık diff'te görünür. {{ kontrolü doğrudan sendTemplateEmail'in değişken çözme mantığını sınar. List-Unsubscribe kontrolü ise mailer config'inin doğru başlıkları eklediğini garanti eder.
GitHub Actions: Mailpit'i service container olarak koş
CI'da Mailpit'i job'a bir service container olarak bağlarsınız. Runner üzerinde çalışan (container-job olmayan) bir job'da service'e localhost üzerinden erişilir:
yaml
jobs:
test:
runs-on: ubuntu-latest
services:
mailpit:
image: axllent/mailpit
ports:
- 1025:1025
- 8025:8025
env:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
env:
SMTP_HOST: localhost
SMTP_PORT: 1025
SMTP_SECURE: "false"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Mailpit hazır olsun
run: |
for i in $(seq 1 30); do
if curl -sf http://localhost:8025/readyz; then exit 0; fi
sleep 1
done
echo "Mailpit hazır olmadı"; exit 1
- run: npm test
readyz endpoint'i ile hazır bekleme, sabit sleep 5'ten çok daha sağlamdır — Mailpit gerçekten mesaj kabul etmeye hazır olana kadar bekler. Bir uyarı: job'unuzu kendisi bir container içinde çalıştırıyorsanız (container: anahtarı), o zaman localhost değil service adını (mailpit:1025) kullanmanız gerekir; networking farkı buradan gelir. Testler kırıldığında hata ayıklamayı kolaylaştırmak için, başarısız adımda mesajı Mailpit'ten çekip artifact olarak yükleyin: curl http://localhost:8025/api/v1/message/latest/raw > failed-email.eml.
Yüzeysel eşitlikten öteye: HTML uyumluluğu, linkler, spam skoru
"Doğru alıcıya doğru konu gitti" testin tabanı. Mailpit sizi tabanın üstüne çıkarır ve hepsi API'den gelir:
`GET /api/v1/message/{ID}/html-check` — HTML/CSS'inizin Outlook, Gmail, Apple Mail gibi istemcilerdeki destek yüzdesini döner. CI'da eşik koyun: skor %90 altındaysa uyar. Bu, "flexbox kullandım ve Outlook'ta layout dağıldı" sınıfı hataları şablon aşamasında yakalar.
`GET /api/v1/message/{ID}/link-check` — gövdedeki tüm linkleri gezip kırık veya 301'e düşenleri raporlar. Canlı domainlere gitmesini istemiyorsanız dış istekleri kapatın.
`MP_ENABLE_SPAMASSASSIN=<host:port>` — SpamAssassin skorunu hesaplatır. Genel eşik 5.0; skorunuz bunun üstüne çıkıyorsa CI'yı fail edin. Bir şablon değişikliğinin spam skorunu 3.1'den 5.4'e taşıdığını merge'den önce görmek paha biçilmez.
`MP_ENABLE_CHAOS=true` — SMTP hatalarını ve gecikmelerini enjekte eder. Uygulamanızın retry ve kuyruk mantığını test etmek için altın: geçici 450 hatalarında yeniden deniyor mu, yoksa mesajı düşürüyor mu?
Bunlar süs değil, regresyon kalkanı. Ama flakiness eğilimleri var (özellikle link-check dış ağa çıkıyorsa), o yüzden bunları ana test job'undan ayrı, non-blocking bir job'a koyun; ana suite'i düşürmeden sinyal verirler.
Pratik checklist ve sık tuzaklar
`beforeEach`'te `DELETE /api/v1/messages` çağır. Önceki testin mesajı kutuda kalırsa arama yanlış mesajı bulur, testler birbirine sızar.
Assert öncesi mutlaka poll/retry kullan, sabit `sleep` koyma. SMTP teslimi ile test assert'i yarış halinde; 200ms aralık, 5sn tavan iyi bir başlangıç.
Başlıkları decode edilmiş halde karşılaştır. Mailpit Subject'i çözülmüş verir; =?UTF-8?...?= ham string'le karşılaştırmaya çalışırsan yanlış şeyi test edersin.
CI'da `readyz` ile hazır bekle, `sleep 5` kullanma. Service container erken görünüp geç dinlemeye başlayabilir.
Mailpit'i prod imajına sızdırma. Sadece test/dev compose dosyasında olsun; prod'da SMTP relay'in gerçek olmalı.
Auth deniyorsan `MP_SMTP_AUTH_ACCEPT_ANY=1` ver. Yoksa Mailpit TLS'siz auth'u reddeder ve teslim başarısız olur, ama hata mesajı bunu net söylemez.
HTML-check ve link-check'i ayrı non-blocking job yap. Dış ağa çıkan kontroller flaky olabilir; ana suite'i rehin almasınlar.
Test izolasyonunu benzersiz konu/etiketle çöz. Paralel testlerde arama yanlış mesajı yakalamasın diye konuya test-özel bir token ekle: subject:"Şifre sıfırlama [test-a1b2]".
E-posta artık "umarım gitmiştir, staging'de iyi görünüyordu" değil. Konusu, gövdesi, encoding'i, linkleri ve spam skoru diff'te görünen, kırıldığında build'i durduran bir test.