fix: distinguish email service errors and validate send receipts
This commit is contained in:
@@ -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.
|
- 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.
|
- 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ı.
|
- 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ı.
|
||||||
|
|||||||
@@ -1461,6 +1461,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (url.pathname === '/api/notify/email/config') {
|
if (url.pathname === '/api/notify/email/config') {
|
||||||
// 'from' gizli bilgi değil; hangi adresin kullanıldığını görünür kılar
|
// 'from' gizli bilgi değil; hangi adresin kullanıldığını görünür kılar
|
||||||
return json(res, 200, {
|
return json(res, 200, {
|
||||||
|
success: true,
|
||||||
configured: emailConfigured(),
|
configured: emailConfigured(),
|
||||||
from: emailFrom(),
|
from: emailFrom(),
|
||||||
envAyarli: Boolean((process.env.EMAIL_FROM || '').trim()),
|
envAyarli: Boolean((process.env.EMAIL_FROM || '').trim()),
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
import {emailConfiguration,sendEmailTest} from '../../services/emailNotifications';
|
||||||
import {DeliveryQueue} from './DeliveryQueue';
|
import {DeliveryQueue} from './DeliveryQueue';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useApp } from '../../context/AppContext';
|
import { useApp } from '../../context/AppContext';
|
||||||
import { Smartphone, Wifi, WifiOff, Download, Bell, CheckCircle2, Moon, Mail, Send } from 'lucide-react';
|
import { Smartphone, Wifi, WifiOff, Download, Bell, CheckCircle2, Moon, Mail, Send } from 'lucide-react';
|
||||||
import { getToken } from '../../services/auth';
|
import { getToken } from '../../services/auth';
|
||||||
import { NotifySubscriptions } from './NotifySubscriptions';
|
import { NotifySubscriptions } from './NotifySubscriptions';
|
||||||
|
|
||||||
export const PwaSettings = () => {
|
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();
|
const { userProfile, authUser, tenders, reminderSettings, saveReminderSettings, reminderSaving, reminderError, requestPushPermission, notify, isQuietNow } = useApp();
|
||||||
// Gerçek Cache API sayımı — sahte "102 ihale önbellekte" yerine
|
// Gerçek Cache API sayımı — sahte "102 ihale önbellekte" yerine
|
||||||
const [onbellekSayisi, setOnbellekSayisi] = useState(null);
|
const [onbellekSayisi, setOnbellekSayisi] = useState(null);
|
||||||
@@ -37,35 +42,25 @@ export const PwaSettings = () => {
|
|||||||
const [emailCfg, setEmailCfg] = useState(null); // null=bilinmiyor, true/false
|
const [emailCfg, setEmailCfg] = useState(null); // null=bilinmiyor, true/false
|
||||||
const [emailSending, setEmailSending] = useState(false);
|
const [emailSending, setEmailSending] = useState(false);
|
||||||
const [emailResult, setEmailResult] = useState(null);
|
const [emailResult, setEmailResult] = useState(null);
|
||||||
useEffect(() => {
|
const [emailConfigError,setEmailConfigError]=useState(null),[emailConfigAttempt,setEmailConfigAttempt]=useState(0);
|
||||||
fetch('/api/notify/email/config')
|
const alive=useRef(true),sendingLock=useRef(false),sendLifetime=useRef(new AbortController());
|
||||||
.then((r) => r.json())
|
useEffect(()=>{alive.current=true;sendLifetime.current=new AbortController();return()=>{alive.current=false;sendLifetime.current.abort();};},[]);
|
||||||
.then((d) => setEmailCfg(Boolean(d.configured)))
|
useEffect(()=>{
|
||||||
.catch(() => setEmailCfg(false));
|
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 () => {
|
const sendTestEmail=async()=>{
|
||||||
setEmailSending(true);
|
if(sendingLock.current || emailCfg!==true)return;
|
||||||
setEmailResult(null);
|
sendingLock.current=true;setEmailSending(true);setEmailResult(null);
|
||||||
try {
|
try {
|
||||||
const yeniler = tenders.filter((t) => t.isYeni).slice(0, 15);
|
const data=await sendEmailTest(tenders.filter(t=>t.isYeni).slice(0,15),{signal:sendLifetime.current.signal});
|
||||||
const res = await fetch('/api/notify/email/test', {
|
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.`});
|
||||||
method: 'POST',
|
}catch(error){if(alive.current)setEmailResult({ok:false,msg:error.message});}
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
finally{sendingLock.current=false;if(alive.current)setEmailSending(false);}
|
||||||
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 handleEnablePush = async () => {
|
const handleEnablePush = async () => {
|
||||||
@@ -227,30 +222,32 @@ export const PwaSettings = () => {
|
|||||||
<span className={`font-extrabold text-[10px] px-2 py-0.5 rounded-full ${
|
<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 ? '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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-slate-600 leading-relaxed">
|
<p className="text-xs text-slate-600 leading-relaxed">
|
||||||
{emailCfg === false
|
{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.'}
|
: 'Takip kriterlerinize uyan ihaleler, hesabınızın e-posta adresine özet olarak gönderilir. Aşağıdan kendinize test gönderebilirsiniz.'}
|
||||||
</p>
|
</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>
|
<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
|
<button
|
||||||
onClick={sendTestEmail}
|
onClick={sendTestEmail}
|
||||||
disabled={emailSending || emailCfg !== true || !authUser}
|
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"
|
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" />
|
<Send className="w-3.5 h-3.5" />
|
||||||
{emailSending ? 'Gönderiliyor…' : 'Kendime Test E-postası Gönder'}
|
{emailSending ? 'Gönderiliyor…' : 'Kendime Test E-postası Gönder'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{emailResult && (
|
{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}
|
{emailResult.ok ? '✓ ' : '⚠ '}{emailResult.msg}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user