Include complete digest batches and validate email transport outcomes

This commit is contained in:
2026-09-06 13:01:18 +03:00
parent 4c4ba0c4c8
commit cbfdd072d1
4 changed files with 40 additions and 8 deletions
+7
View File
@@ -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.
+6 -3
View File
@@ -23,33 +23,36 @@ export function emailFrom() {
return process.env.EMAIL_FROM || 'İhaleist <onboarding@resend.dev>';
}
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) => `<tr>
<td style="padding:8px 12px;border-bottom:1px solid #eef1f5;font-size:13px;color:#0e1526">${escapeHtml(t.baslik || '')}</td>
+22
View File
@@ -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:'<p>QA</p>',from:'Pinned <qa@example.test>',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:'<script>',tenders:Array.from({length:25},(_,i)=>({baslik:'KALEM-'+i,sehir:'<img>',tarih:'10.09.2026'}))});
for(let i=0;i<25;i++)assert.ok(html.includes('>KALEM-'+i+'<'));
assert.ok(html.includes('&lt;script&gt;'));assert.ok(html.includes('&lt;img&gt;'));assert.equal(html.includes('<script>'),false);
});
+5 -5
View File
@@ -8,13 +8,13 @@ test('notification eligibility excludes expired cancelled and unknown deadlines'
for(const patch of [{durum:'İptal'},{durum:'Sonuçlandı'},{id:'hist-1'},{tarih:'01.09.2026'},{saat:''},{tarih:''}])assert.equal(bildirilebilir({...future,...patch},now()),false);
});
test('delivery failure retains pending items and batches acknowledge only included tenders',async()=>{
const rows=Array.from({length:28},(_,i)=>({...future,id:'t-'+i,ikn:'2026/'+i}));
const rows=Array.from({length:28},(_,i)=>({...future,id:'t-'+i,ikn:'2026/'+i,baslik:'Laboratuvar '+i}));
const subscription={id:1,email:'qa@example.test',initialized:true,sonGorulenler:[]};
const sent=[];let fail=true;
const dependencies={now,configured:()=>true,telegramReady:()=>false,readSubscriptions:()=>[subscription],saveSent:(id,ids)=>{sent.push(ids);subscription.sonGorulenler.push(...ids);},deliverEmail:async()=>{if(fail)throw new Error('provider unavailable');}};
const sent=[],messages=[];let fail=true;
const dependencies={now,configured:()=>true,telegramReady:()=>false,readSubscriptions:()=>[subscription],saveSent:(id,ids)=>{sent.push(ids);subscription.sonGorulenler.push(...ids);},deliverEmail:async message=>{if(fail)throw new Error('provider unavailable');messages.push(message);}};
assert.equal((await bildirimleriIsle(async()=>({tenders:rows}),{},dependencies)).hata,1);assert.equal(sent.length,0);
fail=false;assert.equal((await bildirimleriIsle(async()=>({tenders:rows}),{},dependencies)).gonderilen,1);assert.equal(sent[0].length,25);
await bildirimleriIsle(async()=>({tenders:rows}),{},dependencies);assert.equal(sent[1].length,3);
fail=false;assert.equal((await bildirimleriIsle(async()=>({tenders:rows}),{},dependencies)).gonderilen,1);assert.equal(sent[0].length,25);assert.match(messages[0].html,/>Laboratuvar 24</);assert.doesNotMatch(messages[0].html,/>Laboratuvar 25</);
await bildirimleriIsle(async()=>({tenders:rows}),{},dependencies);assert.equal(sent[1].length,3);assert.match(messages[1].html,/>Laboratuvar 27</);
await bildirimleriIsle(async()=>({tenders:rows}),{},dependencies);assert.equal(sent.length,2);
});
test('baseline is distinct from delivery and overlapping turns do not send twice',async()=>{