Include complete digest batches and validate email transport outcomes
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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
@@ -23,33 +23,36 @@ export function emailFrom() {
|
|||||||
return process.env.EMAIL_FROM || 'İhaleist <onboarding@resend.dev>';
|
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();
|
const key = (process.env.RESEND_API_KEY || '').trim();
|
||||||
if (!emailConfigured()) {
|
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.");
|
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 (!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, {
|
const res = await fetch(RESEND_ENDPOINT, {
|
||||||
|
signal:AbortSignal.timeout(30000),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${key}`,
|
Authorization: `Bearer ${key}`,
|
||||||
'Content-Type': 'application/json',
|
'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(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(data?.message || `Resend hatası: HTTP ${res.status}`);
|
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 };
|
return { id: data.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
// İhale bildirimi için basit, markasız HTML gövdesi
|
// İhale bildirimi için basit, markasız HTML gövdesi
|
||||||
export function tenderDigestHtml({ name, tenders }) {
|
export function tenderDigestHtml({ name, tenders }) {
|
||||||
const rows = (tenders || [])
|
const rows = (tenders || [])
|
||||||
.slice(0, 15)
|
|
||||||
.map(
|
.map(
|
||||||
(t) => `<tr>
|
(t) => `<tr>
|
||||||
<td style="padding:8px 12px;border-bottom:1px solid #eef1f5;font-size:13px;color:#0e1526">${escapeHtml(t.baslik || '')}</td>
|
<td style="padding:8px 12px;border-bottom:1px solid #eef1f5;font-size:13px;color:#0e1526">${escapeHtml(t.baslik || '')}</td>
|
||||||
|
|||||||
@@ -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('<script>'));assert.ok(html.includes('<img>'));assert.equal(html.includes('<script>'),false);
|
||||||
|
});
|
||||||
@@ -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);
|
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()=>{
|
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 subscription={id:1,email:'qa@example.test',initialized:true,sonGorulenler:[]};
|
||||||
const sent=[];let fail=true;
|
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()=>{if(fail)throw new Error('provider unavailable');}};
|
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);
|
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);
|
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);
|
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);
|
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()=>{
|
test('baseline is distinct from delivery and overlapping turns do not send twice',async()=>{
|
||||||
|
|||||||
Reference in New Issue
Block a user