fix: distinguish email service errors and validate send receipts

This commit is contained in:
2026-09-06 14:16:08 +03:00
parent a25dcb7a5d
commit e711da9567
5 changed files with 66 additions and 32 deletions
+6
View File
@@ -1029,3 +1029,9 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- Sessiz saat hesabı ortak fonksiyona taşındı. Gece yarısı, başlangıç dahil/bitiş hariç ve eşit başlangıç-bitiş davranışı doğrulandı. Saatlerin cihazın yerel saatine göre olduğu açıkça yazıldı. Geciken bildirim callback'leri güncel ayar ref'ini okur; eski sessiz saat/pushEnabled closure'ı kullanılmaz.
- Gerçek e-posta gönderiminde kullanılmayan E-posta bildirimleri açık kutusu kaldırıldı; kullanıcı gerçek bildirim aboneliklerine yönlendirilir. Tarayıcı/sessiz saat seçiminin e-posta aboneliğini kapatmadığı belirtilir. Tarayıcı ayarında açık uygulama bildirimi ve kapalı uygulamaya arka plan push teslimi ayrımı yapıldı; bir gösterim denemesi kesin teslim olarak sunulmaz.
- 233 otomatik test ve derleme geçti. Saat sınırları, geçersiz ayarlar, eski sürümle yazım ve mevcut ayarın korunması test edildi. Gerçek tarayıcı izin penceresi, yeni ayarların tarayıcı kalıcılığı ve iki sekmeli UI çatışması açık. E-posta yapılandırma/test gönderimi HTTP hata davranışı ayrıca incelenmeli. Dış e-posta veya bildirim gönderilmedi; canlıya yayın yapılmadı.
### E-posta durum ve test gönderimi istemci güvenilirliği — 6 Eylül 2026
- E-posta yapılandırma isteği ortak süre/oturum kontrollü HTTP servisini kullanır. Ağ/HTTP/yanıt hatası artık yapılandırılmamış hizmet gibi gösterilmez; ayrı hata ve durum yenileme düğmesi vardır. Config endpoint success alanını da döndürür; configured boolean doğrulanır.
- Test gönderimi aynı servisin zaman aşımı, iptal ve oturum kontrollerinden geçer. Tek işlem kilidi, panel kapanınca iptal ve hesap/token değişince panelin yeniden kurulması eklendi. HTTP başarılı olmayan veya sağlayıcı kayıt numarası/alıcı içermeyen yanıt başarı sayılmaz; otomatik tekrar yapılmaz.
- Başarı metni sağlayıcının gönderimi kabul ettiğini söyler; posta kutusuna teslim iddiası ve buna bağlı ikinci sahte başarı bildirimi kaldırıldı. Normal kullanıcı ekranındaki sunucu environment kurulum ayrıntıları yöneticiyle görüşme açıklamasına dönüştürüldü.
- 235 otomatik test ve derleme geçti. Config hata/kurulmamış ayrımı, eksik yanıt, HTTP başarısızlığı ve sağlayıcı makbuzu zorunluluğu sahte fetch ile sınandı; gerçek e-posta gönderilmedi. Yeni durum yenileme ve test gönderim ekranının tarayıcı kabulü, gerçek test alıcısına teslim ve sunucu test endpoint idempotency ayrıca açık. Canlıya yayın yapılmadı.
+1
View File
@@ -1461,6 +1461,7 @@ const server = http.createServer(async (req, res) => {
if (url.pathname === '/api/notify/email/config') {
// 'from' gizli bilgi değil; hangi adresin kullanıldığını görünür kılar
return json(res, 200, {
success: true,
configured: emailConfigured(),
from: emailFrom(),
envAyarli: Boolean((process.env.EMAIL_FROM || '').trim()),
+29 -32
View File
@@ -1,11 +1,16 @@
import {emailConfiguration,sendEmailTest} from '../../services/emailNotifications';
import {DeliveryQueue} from './DeliveryQueue';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useApp } from '../../context/AppContext';
import { Smartphone, Wifi, WifiOff, Download, Bell, CheckCircle2, Moon, Mail, Send } from 'lucide-react';
import { getToken } from '../../services/auth';
import { NotifySubscriptions } from './NotifySubscriptions';
export const PwaSettings = () => {
const {authUser}=useApp();
return <PwaPanel key={`${authUser?.email}:${getToken()}`}/>;
};
const PwaPanel = () => {
const { userProfile, authUser, tenders, reminderSettings, saveReminderSettings, reminderSaving, reminderError, requestPushPermission, notify, isQuietNow } = useApp();
// Gerçek Cache API sayımı — sahte "102 ihale önbellekte" yerine
const [onbellekSayisi, setOnbellekSayisi] = useState(null);
@@ -37,35 +42,25 @@ export const PwaSettings = () => {
const [emailCfg, setEmailCfg] = useState(null); // null=bilinmiyor, true/false
const [emailSending, setEmailSending] = useState(false);
const [emailResult, setEmailResult] = useState(null);
useEffect(() => {
fetch('/api/notify/email/config')
.then((r) => r.json())
.then((d) => setEmailCfg(Boolean(d.configured)))
.catch(() => setEmailCfg(false));
}, []);
const [emailConfigError,setEmailConfigError]=useState(null),[emailConfigAttempt,setEmailConfigAttempt]=useState(0);
const alive=useRef(true),sendingLock=useRef(false),sendLifetime=useRef(new AbortController());
useEffect(()=>{alive.current=true;sendLifetime.current=new AbortController();return()=>{alive.current=false;sendLifetime.current.abort();};},[]);
useEffect(()=>{
const controller=new AbortController();
setEmailCfg(null);setEmailConfigError(null);
emailConfiguration({signal:controller.signal}).then(data=>{if(!controller.signal.aborted)setEmailCfg(data.configured);})
.catch(error=>{if(!controller.signal.aborted)setEmailConfigError(error.message);});
return()=>controller.abort();
},[emailConfigAttempt]);
const sendTestEmail = async () => {
setEmailSending(true);
setEmailResult(null);
const sendTestEmail=async()=>{
if(sendingLock.current || emailCfg!==true)return;
sendingLock.current=true;setEmailSending(true);setEmailResult(null);
try {
const yeniler = tenders.filter((t) => t.isYeni).slice(0, 15);
const res = await fetch('/api/notify/email/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ subject: 'İhaleist — bildirim testi', tenders: yeniler }),
});
const data = await res.json();
if (data.success) {
setEmailResult({ ok: true, msg: `E-posta gönderildi → ${data.to}` });
notify('E-posta gönderildi', `Test e-postası ${data.to} adresine iletildi.`);
} else {
setEmailResult({ ok: false, msg: data.error || 'Gönderilemedi' });
}
} catch (e) {
setEmailResult({ ok: false, msg: e.message });
} finally {
setEmailSending(false);
}
const data=await sendEmailTest(tenders.filter(t=>t.isYeni).slice(0,15),{signal:sendLifetime.current.signal});
if(alive.current)setEmailResult({ok:true,msg:`E-posta sağlayıcısı gönderimi kabul etti → ${data.to}. Posta kutusuna teslim ayrıca doğrulanmalıdır.`});
}catch(error){if(alive.current)setEmailResult({ok:false,msg:error.message});}
finally{sendingLock.current=false;if(alive.current)setEmailSending(false);}
};
const handleEnablePush = async () => {
@@ -227,30 +222,32 @@ export const PwaSettings = () => {
<span className={`font-extrabold text-[10px] px-2 py-0.5 rounded-full ${
emailCfg === true ? 'bg-emerald-100 text-emerald-800' : emailCfg === false ? 'bg-amber-100 text-amber-800' : 'bg-slate-100 text-slate-600'
}`}>
{emailCfg === true ? 'Aktif (Resend)' : emailCfg === false ? 'Yapılandırılmadı' : 'Kontrol ediliyor…'}
{emailConfigError ? 'Durum alınamadı' : emailCfg === true ? 'Yapılandırıldı (Resend)' : emailCfg === false ? 'Yapılandırılmadı' : 'Kontrol ediliyor…'}
</span>
</div>
<p className="text-xs text-slate-600 leading-relaxed">
{emailCfg === false
? 'E-posta göndermek için sunucuya RESEND_API_KEY ekleyin (Coolify → Environment). Ayrıca gönderen domainini Resend’de DNS ile doğrulayın.'
? 'E-posta hizmeti henüz etkinleştirilmedi. Kurulum için yöneticinizle görüşün.'
: 'Takip kriterlerinize uyan ihaleler, hesabınızın e-posta adresine özet olarak gönderilir. Aşağıdan kendinize test gönderebilirsiniz.'}
</p>
{emailConfigError&&<p role="alert" className="text-xs text-red-700">{emailConfigError}</p>}
<button type="button" disabled={emailSending} onClick={()=>setEmailConfigAttempt(n=>n+1)} className="text-xs underline">E-posta durumunu yenile</button>
<p className="text-xs text-slate-600">E-posta gönderimlerini aşağıdaki bildirim aboneliklerinden yönetin. Tarayıcı bildirim seçimi ve sessiz saatler e-posta aboneliklerini kapatmaz.</p>
<button
onClick={sendTestEmail}
disabled={emailSending || emailCfg !== true || !authUser}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white font-bold text-xs py-2.5 rounded-xl flex items-center justify-center gap-2"
title={!authUser ? 'Giriş yapın' : emailCfg !== true ? 'RESEND_API_KEY gerekli' : 'Kendinize test e-postası gönder'}
title={!authUser ? 'Giriş yapın' : emailCfg !== true ? 'E-posta hizmetinin etkin olduğunu doğrulayın' : 'Kendinize test e-postası gönder'}
>
<Send className="w-3.5 h-3.5" />
{emailSending ? 'Gönderiliyor…' : 'Kendime Test E-postası Gönder'}
</button>
{emailResult && (
<p className={`text-[11px] ${emailResult.ok ? 'text-emerald-600' : 'text-red-600'}`}>
<p role={emailResult.ok ? 'status' : 'alert'} className={`text-[11px] ${emailResult.ok ? 'text-emerald-600' : 'text-red-600'}`}>
{emailResult.ok ? '✓ ' : '⚠ '}{emailResult.msg}
</p>
)}
+11
View File
@@ -0,0 +1,11 @@
import {accountRequest} from './accountRequest.js';
export async function emailConfiguration({signal}={}) {
const data=await accountRequest('/api/notify/email/config',{signal});
if(typeof data.configured!=='boolean')throw new Error('E-posta hizmetinin durumu doğrulanamadı. Yeniden deneyin.');
return data;
}
export async function sendEmailTest(tenders,{signal}={}) {
const data=await accountRequest('/api/notify/email/test',{method:'POST',signal,body:JSON.stringify({subject:'İhaleist — bildirim testi',tenders})});
if(typeof data.id!=='string'||!data.id||typeof data.to!=='string'||!data.to)throw new Error('Gönderim sonucu doğrulanamadı. Yeniden göndermeden önce posta kutunuzu kontrol edin.');
return data;
}
+19
View File
@@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {emailConfiguration,sendEmailTest} from '../src/services/emailNotifications.js';
test('email configuration errors differ from an explicitly unconfigured service',async()=>{
const fetch=globalThis.fetch;
try{
globalThis.fetch=async()=>Response.json({success:false,error:'Unavailable'},{status:503});await assert.rejects(emailConfiguration(),/Unavailable/);
globalThis.fetch=async()=>Response.json({success:true,configured:false});assert.equal((await emailConfiguration()).configured,false);
globalThis.fetch=async()=>Response.json({success:true});await assert.rejects(emailConfiguration(),/doğrulanamadı/);
}finally{globalThis.fetch=fetch;}
});
test('email test requires HTTP success and a provider receipt, without retrying',async()=>{
const fetch=globalThis.fetch;let calls=0;
try{
globalThis.fetch=async()=>{calls++;return Response.json({success:true,to:'qa@example.test'});};await assert.rejects(sendEmailTest([]),/posta kutunuzu/);assert.equal(calls,1);
globalThis.fetch=async()=>Response.json({success:true,id:'qa-id',to:'qa@example.test'},{status:502});await assert.rejects(sendEmailTest([]));
globalThis.fetch=async()=>Response.json({success:true,id:'qa-id',to:'qa@example.test'});assert.equal((await sendEmailTest([])).id,'qa-id');
}finally{globalThis.fetch=fetch;}
});