diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 577ba52..4825ce0 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -807,3 +807,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö - Süresi geçmiş, iptal/sonuç/arşiv ve tarih-saati doğrulanamayan ilanlar yeni ihale e-postasına alınmaz. Aynı süreçte üst üste başlayan turlar için çalışma kilidi eklendi. - 181 otomatik test geçti. Kontrollü sağlayıcı hatası→hiçbir işaretleme, 28 kayıt→25+3 dağılımı, başlangıçta gönderim olmaması, 450+ kayıt geçmişi ve eşzamanlı tur koruması test edildi. Gerçek e-posta gönderilmedi. - Sağlayıcının e-postayı kabul edip yerel kayıt öncesi süreç kapanması halinde tekrar gönderim ihtimali sürer; çok süreçli gönderim kilidi ve Telegram için ayrı teslim kuyruğu henüz yok. Bu çalışma dış teslim/eksiksiz outbox kabulü değildir. + +### E-posta gövdesi ve taşıma doğrulaması — 6 Eylül 2026 +- Ek bulgu: notifier 25 kaydı seçse bile tenderDigestHtml kendi içinde 15 kayda kesiyordu. Şablonun gizli sınırı kaldırıldı; seçilen tüm kayıtlar gövdeye giriyor. 25+3 dağılım testi artık gerçek HTML şablonunda 24./27. indeksli son kayıtları da kontrol ediyor. +- sendEmail 30 saniyelik fetch sınırı, sabitlenebilir from alanı ve isteğe bağlı Idempotency-Key desteği aldı. HTTP başarılı olsa bile sağlayıcı gönderim id'si yoksa belirsiz sonuç hatası oluşur. +- Resend belgelerine göre idempotency anahtarları 24 saat tutulur: https://resend.com/docs/dashboard/emails/idempotency-keys . Bu nedenle kalıcı kuyruğun süresi dolan belirsiz işi sessizce yeniden göndermemesi gerekir. +- 183 otomatik test geçti. Gövdenin 25 kaydı kapsaması, HTML kaçırma, gönderici/anahtar aktarımı ve eksik gönderim kimliği hatası kontrollü testlerle doğrulandı. Gerçek e-posta gönderilmedi. +- Anahtar desteği henüz notifier'ın kalıcı gönderim kaydına bağlanmadı; kalıcı outbox işi açık. Bu değişiklik tekrar gönderimin bütünüyle önlendiği anlamına gelmez. diff --git a/server/email.mjs b/server/email.mjs index 3f4c742..322b1b3 100644 --- a/server/email.mjs +++ b/server/email.mjs @@ -23,33 +23,36 @@ export function emailFrom() { return process.env.EMAIL_FROM || 'İhaleist '; } -export async function sendEmail({ to, subject, html, text }) { +export async function sendEmail({ to, subject, html, text, from = emailFrom(), idempotencyKey }) { const key = (process.env.RESEND_API_KEY || '').trim(); if (!emailConfigured()) { throw new Error("E-posta yapılandırılmamış — Coolify'da RESEND_API_KEY değerine gerçek anahtarı (re_...) yapıştırın."); } if (!to || !subject || (!html && !text)) throw new Error('Eksik alan: to, subject ve html/text gerekli'); + if(idempotencyKey!==undefined && (typeof idempotencyKey!=='string'||!idempotencyKey||idempotencyKey.length>256||/[\r\n]/.test(idempotencyKey)))throw new Error('Geçersiz e-posta istek kimliği.'); const res = await fetch(RESEND_ENDPOINT, { + signal:AbortSignal.timeout(30000), method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', + ...(idempotencyKey?{'Idempotency-Key':idempotencyKey}:{}), }, - body: JSON.stringify({ from: emailFrom(), to: Array.isArray(to) ? to : [to], subject, html, text }), + body: JSON.stringify({ from, to: Array.isArray(to) ? to : [to], subject, html, text }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data?.message || `Resend hatası: HTTP ${res.status}`); } + if(typeof data.id!=='string'||!data.id.trim())throw new Error('E-posta sağlayıcısı gönderim kimliği döndürmedi; gönderim sonucu belirsiz.'); return { id: data.id }; } // İhale bildirimi için basit, markasız HTML gövdesi export function tenderDigestHtml({ name, tenders }) { const rows = (tenders || []) - .slice(0, 15) .map( (t) => ` ${escapeHtml(t.baslik || '')} diff --git a/tests/email-transport.test.mjs b/tests/email-transport.test.mjs new file mode 100644 index 0000000..448d6ae --- /dev/null +++ b/tests/email-transport.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {sendEmail,tenderDigestHtml} from '../server/email.mjs'; +test('email transport preserves pinned sender and idempotency key, bounds requests and rejects ambiguous success',async()=>{ + const original=globalThis.fetch,oldKey=process.env.RESEND_API_KEY;process.env.RESEND_API_KEY='re_synthetic_only'; + try { + let request; + globalThis.fetch=async(url,options)=>{request=options;return {ok:true,json:async()=>({id:'provider-id'})};}; + const mail={to:'qa@example.test',subject:'QA',html:'

QA

',from:'Pinned ',idempotencyKey:'notify/123'}; + assert.equal((await sendEmail(mail)).id,'provider-id'); + assert.equal(request.headers['Idempotency-Key'],'notify/123');assert.equal(JSON.parse(request.body).from,mail.from);assert.ok(request.signal instanceof AbortSignal); + globalThis.fetch=async()=>({ok:true,json:async()=>({})}); + await assert.rejects(sendEmail(mail),/sonucu belirsiz/); + globalThis.fetch=async()=>assert.fail('invalid key must not reach provider'); + await assert.rejects(sendEmail({...mail,idempotencyKey:'bad\r\nkey'}),/Geçersiz/); + }finally {globalThis.fetch=original;if(oldKey===undefined)delete process.env.RESEND_API_KEY;else process.env.RESEND_API_KEY=oldKey;} +}); +test('digest includes every selected tender and escapes untrusted content',()=>{ + const html=tenderDigestHtml({name:'