diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index d97f351..445b5f8 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -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ı. diff --git a/server/index.js b/server/index.js index f63f7ab..17cb0d0 100644 --- a/server/index.js +++ b/server/index.js @@ -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()), diff --git a/src/components/tools/PwaSettings.jsx b/src/components/tools/PwaSettings.jsx index e8bc6a1..4152f14 100644 --- a/src/components/tools/PwaSettings.jsx +++ b/src/components/tools/PwaSettings.jsx @@ -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 ; +}; +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 = () => { - {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…'}

{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.'}

+ {emailConfigError&&

{emailConfigError}

} +

E-posta gönderimlerini aşağıdaki bildirim aboneliklerinden yönetin. Tarayıcı bildirim seçimi ve sessiz saatler e-posta aboneliklerini kapatmaz.

{emailResult && ( -

+

{emailResult.ok ? '✓ ' : '⚠ '}{emailResult.msg}

)} diff --git a/src/services/emailNotifications.js b/src/services/emailNotifications.js new file mode 100644 index 0000000..4a8c116 --- /dev/null +++ b/src/services/emailNotifications.js @@ -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; +} diff --git a/tests/email-notifications.test.mjs b/tests/email-notifications.test.mjs new file mode 100644 index 0000000..6059fa4 --- /dev/null +++ b/tests/email-notifications.test.mjs @@ -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;} +});