From aac33e7533849148184c88a432e911022ab7ae18 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Mon, 21 Sep 2026 04:47:30 +0300 Subject: [PATCH 1/5] =?UTF-8?q?chore(eposta):=20=C5=9Fablondan=20harici=20?= =?UTF-8?q?font=20+=20footer=20ileti=C5=9Fim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'deki Google Fonts preconnect/stylesheet 'leri kaldırıldı (çoğu istemci yok sayıyor, bazı spam filtrelerinde eksi puan). - HTML footer'a ve düz metin gövdelerin sonuna kolaytercih.com + destek@kolaytercih.com satırı eklendi. Satış dili yok. Co-Authored-By: Claude Fable 5.1 --- src/lib/eposta.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/lib/eposta.ts b/src/lib/eposta.ts index 996149b..f5dc673 100644 --- a/src/lib/eposta.ts +++ b/src/lib/eposta.ts @@ -6,8 +6,9 @@ import "server-only"; * desteğini güvenilir biçimde sunmaz — bu yüzden düzen tablo tabanlı ve tüm * stiller satır içi. Logo işareti sitedeki SVG'nin raster kopyası olan * `/kolay-tercih-mark.png` ile gösterilir (SVG'yi Gmail/Outlook engeller). - * Web fontları (Bricolage Grotesque, Cal Sans) ile progresif olarak - * yüklenir; desteklemeyen istemciler sistem yazı tipine düşer. + * Harici font 'i yok: çoğu istemci yok sayıyor, bazı spam filtreleri + * harici kaynağı eksi puan sayıyor. Cal Sans / Bricolage Grotesque yalnızca + * cihazda kuruluysa görünür; aksi hâlde sistem yazı tipine düşer. */ import { SITE_URL } from "./seo"; @@ -25,6 +26,11 @@ const BASLIK_TIPI = `'Cal Sans', ${YAZI_TIPI}`; /** Sitedeki kelime markası Bricolage Grotesque (font-bricolage). */ const MARKA_TIPI = `'Bricolage Grotesque', ${YAZI_TIPI}`; +/** Alt bilgideki iletişim satırı — HTML footer ve düz metin gövdeler ortak kullanır. */ +const SITE_ALANI = "kolaytercih.com"; +const DESTEK_EPOSTA = "destek@kolaytercih.com"; +const METIN_ALT_BILGI = ["", "—", `${SITE_ALANI} · ${DESTEK_EPOSTA}`]; + export type EpostaIcerik = { subject: string; html: string; @@ -76,8 +82,6 @@ function kabuk({ ${kacir(baslikEtiketi)} - -
${onizleme}
@@ -96,7 +100,8 @@ function kabuk({ KolayTercih · YKS tercih listeni dakikalar içinde hazırla
- Bu e-postayı hesabınla ilgili bir işlem yaptığın için aldın. + Bu e-postayı hesabınla ilgili bir işlem yaptığın için aldın.
+ ${SITE_ALANI} · ${DESTEK_EPOSTA} @@ -155,6 +160,7 @@ export function magicLinkEpostasi(url: string): EpostaIcerik { "", "Bağlantı 15 dakika geçerli ve yalnızca bir kez kullanılabilir.", "Bu isteği sen yapmadıysan bu e-postayı yok sayabilirsin.", + ...METIN_ALT_BILGI, ].join("\n"), }; } @@ -201,6 +207,7 @@ export function krediHatirlatmaEpostasi(): EpostaIcerik { "", "Listenin tamamını açmak ya da danışmana soru sormak istersen kredi", "yükleyebilirsin — listen ve sohbet geçmişin olduğu gibi durur.", + ...METIN_ALT_BILGI, ].join("\n"), }; } \ No newline at end of file From 5992cf3637fc7c1bc4d8b5ecc911795ebe8e0157 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Mon, 21 Sep 2026 04:49:08 +0300 Subject: [PATCH 2/5] fix(eposta): Reply-To + prod'da sessiz fallback yok MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resend gönderimine replyTo eklendi (EMAIL_REPLY_TO, yoksa destek@kolaytercih.com) — mail.kolaytercih.com'un MX'i olmadığı için yanıtlar bounce oluyordu. - epostaKonsolModu(): konsola yazma yalnızca anahtar yokken VE (dev'de ya da EPOSTA_KONSOL_FALLBACK=1 ile) geçerli. Production'da anahtar yoksa artık "gönderildi" dönmüyor, token'lı link loga yazılmıyor; gönderim anında net hata fırlatılıyor (better-auth 500 → formda hata toast'ı). - Production'da EMAIL_FROM yoksa sessizce onboarding@resend.dev'e düşmek yerine hata. Env okumaları çağrı anında — next build etkilenmez. Canlı giriş yolunu değiştirir; merge öncesi lokal docker testi şart. Co-Authored-By: Claude Fable 5.1 --- src/lib/auth.ts | 12 ++++++---- src/lib/eposta-gonder.ts | 49 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 121c003..c95bc3e 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -5,7 +5,7 @@ import { magicLink } from "better-auth/plugins"; import { appDb, schema } from "./appdb"; import { grantCredits, DENEME_KREDISI } from "./credits"; import { magicLinkEpostasi } from "./eposta"; -import { epostaGonder } from "./eposta-gonder"; +import { epostaGonder, epostaKonsolModu } from "./eposta-gonder"; /** * Verify URL'ini maile konacak onay sayfası URL'ine çevirir. Doğrulama linki @@ -30,13 +30,15 @@ async function sendMagicLinkEmail(email: string, url: string) { (globalThis as { __sonMagicLink?: string }).__sonMagicLink = url; } const mailLinki = onaySayfasiUrl(url); - if (!process.env.RESEND_API_KEY) { - // Dev fallback: Resend anahtarı yoksa linki terminale yaz + if (epostaKonsolModu()) { + // Konsol modu (dev ya da EPOSTA_KONSOL_FALLBACK=1): linki terminale yaz. + // Prod'da anahtar yoksa buraya girilmez; epostaGonder hata fırlatır — + // token'lı link prod loglarına düşmez. console.log(`\n[giris] Magic link for ${email}:\n${mailLinki}\n`); return; } - // Gönderim hatası better-auth'a fırlatılır — aksi halde kullanıcı - // "gönderildi" ekranını görür ama mail hiç çıkmaz. + // Gönderim/yapılandırma hatası better-auth'a fırlatılır — aksi halde + // kullanıcı "gönderildi" ekranını görür ama mail hiç çıkmaz. await epostaGonder(email, magicLinkEpostasi(mailLinki)); } diff --git a/src/lib/eposta-gonder.ts b/src/lib/eposta-gonder.ts index a4e6050..aa38b3a 100644 --- a/src/lib/eposta-gonder.ts +++ b/src/lib/eposta-gonder.ts @@ -4,23 +4,62 @@ import type { EpostaIcerik } from "./eposta"; /** * Ortak e-posta göndericisi (Resend). Şablonlar eposta.ts'te, gönderim * burada — magic link (auth.ts) ve kredi hatırlatması aynı yoldan çıkar. - * RESEND_API_KEY yoksa dev fallback: içerik terminale yazılır. + * + * Env okumalarının tamamı ÇAĞRI ANINDA yapılır, modül seviyesinde değil: + * `next build` NODE_ENV=production + env'siz koşar; modül yüklenirken hata + * fırlatmak build'i kırar. */ + +/** Maile yanıt verilirse düşeceği adres — mail.kolaytercih.com'un MX'i yok. */ +const VARSAYILAN_YANIT_ADRESI = "destek@kolaytercih.com"; + +/** Prod'da konsol fallback'ini bilinçli açan kaçış bayrağı (lokal docker testi). */ +function kacisBayragi(): boolean { + return process.env.EPOSTA_KONSOL_FALLBACK === "1"; +} + +/** + * Konsol modu: gönderim yerine içerik terminale yazılır. Yalnızca Resend + * anahtarı yokken ve (dev'de ya da EPOSTA_KONSOL_FALLBACK=1 ile) geçerlidir. + * Production'da anahtar yoksa konsol moduna DÜŞÜLMEZ — giriş token'ı taşıyan + * link prod loglarına yazılmasın, kullanıcı da "gönderildi" sanmasın diye + * epostaGonder net hata fırlatır. + */ +export function epostaKonsolModu(): boolean { + return ( + !process.env.RESEND_API_KEY && + (process.env.NODE_ENV !== "production" || kacisBayragi()) + ); +} + export async function epostaGonder( to: string, icerik: EpostaIcerik, ): Promise { - if (!process.env.RESEND_API_KEY) { + if (epostaKonsolModu()) { console.log( - `\n[eposta] (dev) ${to} adresine gönderilecekti — ${icerik.subject}\n${icerik.text}\n`, + `\n[eposta] (konsol modu) ${to} adresine gönderilecekti — ${icerik.subject}\n${icerik.text}\n`, ); return; } + const anahtar = process.env.RESEND_API_KEY; + if (!anahtar) { + throw new Error("[eposta] RESEND_API_KEY tanımlı değil"); + } + // EMAIL_FROM yoksa resend.dev adresi yalnızca Resend hesap sahibine teslim + // eder — prod'da sessizce buna düşmek yerine yapılandırma hatasını göster. + const gonderen = process.env.EMAIL_FROM; + if (!gonderen && process.env.NODE_ENV === "production" && !kacisBayragi()) { + throw new Error("[eposta] EMAIL_FROM tanımlı değil"); + } const { Resend } = await import("resend"); - const resend = new Resend(process.env.RESEND_API_KEY); + const resend = new Resend(anahtar); const { error } = await resend.emails.send({ - from: process.env.EMAIL_FROM ?? "KolayTercih ", + from: gonderen || "KolayTercih ", to, + // `||`: CI'da secret tanımsızsa env boş string gelir; boş Reply-To ile + // Resend'e gitmek yerine varsayılana düş. + replyTo: process.env.EMAIL_REPLY_TO || VARSAYILAN_YANIT_ADRESI, subject: icerik.subject, html: icerik.html, // Düz metin alternatifi spam puanını düşürür ve HTML'i engelleyen From 2af4a9f7813ee0ca8c7b5adce2ac0517e002ced1 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Mon, 21 Sep 2026 04:55:10 +0300 Subject: [PATCH 3/5] =?UTF-8?q?feat(giris):=20adres=20ba=C5=9F=C4=B1na=206?= =?UTF-8?q?0=20sn=20so=C4=9Fuma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aynı e-posta adresine 60 sn içinde ikinci giriş linki gönderilmez: istek yine 200 döner (form "gönderildi" gösterir), mail çıkmaz. Üçüncü kişiye mail bombardımanını ve Resend kotasının yakılmasını engeller; bugüne dek tek sınır better-auth'un IP limitiydi (5 istek/60 sn). - Kaynak süreç içi Map (globalThis.__girisEpostaSon), anahtar trim+lowercase adres. verification tablosundan türetilmez: orada identifier token'dır ve satır sendMagicLink'ten önce yazılır. - Pencere kaymaz (düşen istek süreyi uzatmaz); her çağrıda eski kayıtlar budanır. Gönderim hata verirse kayıt silinir, kullanıcı hemen yeniden deneyebilir. - Yalnızca NODE_ENV=production: dev paneli "anında giriş" art arda istek atıyor. Konsol modunda da işler (lokal docker testi). Canlı giriş yolunu değiştirir; merge öncesi lokal docker testi şart. Co-Authored-By: Claude Fable 5.1 --- src/lib/auth.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index c95bc3e..1a1df6b 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -22,7 +22,67 @@ function onaySayfasiUrl(verifyUrl: string): string { return hedef.toString(); } +/** Aynı adrese bu süre içinde en çok bir giriş e-postası gider. */ +const GIRIS_SOGUMA_MS = 60_000; + +/** + * Adres → son gönderim zamanı. Süreç içi Map: tek konteyner çalışıyoruz, + * şema/DB yazımı gerektirmez; yeniden başlatmada sıfırlanır (ikinci katman + * better-auth'un IP limiti). `verification` tablosundan türetilmez — orada + * identifier token'dır ve satır sendMagicLink'ten ÖNCE yazılır. globalThis: + * modül birden çok kez yüklense de tek kayıt (appdb/index.ts deseni). + */ +function girisSogumaKaydi(): Map { + const g = globalThis as { __girisEpostaSon?: Map }; + return (g.__girisEpostaSon ??= new Map()); +} + +function sogumaAnahtari(email: string): string { + return email.trim().toLowerCase(); +} + +/** + * Adres soğumada değilse pencereyi başlatır ve `true` döner; soğumadaysa + * `false` (pencere kaymaz — süre ilk gönderimden sayılır). Her çağrıda süresi + * dolmuş kayıtlar budanır, Map büyümez. + */ +export function girisSogumasiAl(email: string, simdi = Date.now()): boolean { + const kayit = girisSogumaKaydi(); + for (const [adres, zaman] of kayit) { + if (simdi - zaman >= GIRIS_SOGUMA_MS) kayit.delete(adres); + } + const anahtar = sogumaAnahtari(email); + if (kayit.has(anahtar)) return false; + kayit.set(anahtar, simdi); + return true; +} + +/** Gönderim başarısızsa pencereyi geri verir — kullanıcı hemen yeniden deneyebilsin. */ +export function girisSogumasiBirak(email: string): void { + girisSogumaKaydi().delete(sogumaAnahtari(email)); +} + async function sendMagicLinkEmail(email: string, url: string) { + // Soğuma yalnızca prod'da: dev paneli "anında giriş" aynı adrese art arda + // istek atıyor. Konsol modunda da işler (lokal docker testi görsün). + const sogumaAcik = process.env.NODE_ENV === "production"; + if (sogumaAcik && !girisSogumasiAl(email)) { + // Sessizce çık: istek 200 döner, form "gönderildi" gösterir, mail çıkmaz — + // üçüncü kişiye mail bombardımanı ve Resend kotası yakılması engellenir. + console.info( + "[giris] Soğuma penceresinde tekrar istek — e-posta gönderilmedi.", + ); + return; + } + try { + await girisLinkiniIlet(email, url); + } catch (err) { + if (sogumaAcik) girisSogumasiBirak(email); + throw err; + } +} + +async function girisLinkiniIlet(email: string, url: string) { if (process.env.NODE_ENV !== "production") { // Dev paneli "anında giriş" için son üretilen linki yakalar (bkz. // src/lib/dev/actions.ts) — panel onay adımını atlayıp doğrudan verify From 17046c830f963f92e2d4744b9fee9a7ce83825f6 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Mon, 21 Sep 2026 04:52:27 +0300 Subject: [PATCH 4/5] =?UTF-8?q?feat(analitik):=20giris=5Fbasarili=20sunucu?= =?UTF-8?q?=20olay=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oturum oluşturulduğunda (databaseHooks.session.create.after) Rybbit'e sunucudan giris_basarili düşer; yontem = eposta | google | diger (better-auth uç yolundan türetilir). Huninin "link istendi → giriş yapıldı" adımı bugüne dek ölçülemiyordu. - await yok: Rybbit'in 3 sn'lik timeout'u girişi geciktirmez; sunucuOlayi hata fırlatmaz. E-posta adresi olaya girmez, yalnızca kullanıcı ID. - RYBBIT_HOST/SITE_ID/API_KEY yoksa no-op — prod .env.production'da bu üçü bugün yok (bkz. ayrı ci.yaml commit'i), eklenene dek olay düşmez. Giriş akışının davranışını değiştirmez (yalnızca yan etki ekler). Co-Authored-By: Claude Fable 5.1 --- src/lib/analitik-sunucu.ts | 4 ++-- src/lib/auth.ts | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/lib/analitik-sunucu.ts b/src/lib/analitik-sunucu.ts index 0c10688..3e2b8cf 100644 --- a/src/lib/analitik-sunucu.ts +++ b/src/lib/analitik-sunucu.ts @@ -1,7 +1,7 @@ import "server-only"; /** * Rybbit'e sunucudan olay gönderimi (POST /api/track). Yalnızca sunucu - * tarafından çağrılır (lib/odeme.ts) — anahtar NEXT_PUBLIC_ değil. + * tarafından çağrılır (lib/odeme.ts, lib/auth.ts) — anahtar NEXT_PUBLIC_ değil. * * Neden sunucu tarafı: ödeme dönüşümü tarayıcıdan gönderilirse reklam * engelleyiciler ve iyzico dönüşünde kapatılan sekmeler yüzünden kaybolur. @@ -14,7 +14,7 @@ const HOST = process.env.RYBBIT_HOST; const SITE_ID = process.env.RYBBIT_SITE_ID; const API_KEY = process.env.RYBBIT_API_KEY; -export type SunucuOlayAdi = "odeme_tamamlandi"; +export type SunucuOlayAdi = "odeme_tamamlandi" | "giris_basarili"; export async function sunucuOlayi( ad: SunucuOlayAdi, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 1a1df6b..680a0eb 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,6 +2,7 @@ import "server-only"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { magicLink } from "better-auth/plugins"; +import { sunucuOlayi } from "./analitik-sunucu"; import { appDb, schema } from "./appdb"; import { grantCredits, DENEME_KREDISI } from "./credits"; import { magicLinkEpostasi } from "./eposta"; @@ -157,6 +158,28 @@ export const auth = betterAuth({ }, }, }, + session: { + create: { + after: async (session, ctx) => { + // Huninin son adımı: oturum gerçekten açıldı. Sunucudan gider — + // reklam engelleyici ve kapanan sekme kaybettirmez. `await` YOK: + // Rybbit'in 3 sn'lik timeout'u girişi geciktirmesin (sunucuOlayi + // hata fırlatmaz). E-posta adresi özelliklere girmez. + const yol = ctx?.path ?? ""; + void sunucuOlayi("giris_basarili", { + kullaniciId: session.userId, + pathname: "/giris", + ozellikler: { + yontem: yol.startsWith("/magic-link") + ? "eposta" + : yol.startsWith("/callback") + ? "google" + : "diger", + }, + }); + }, + }, + }, }, }); From ea3ba5f335f9015d153f8757dbe627ee48431dd7 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Mon, 21 Sep 2026 04:52:53 +0300 Subject: [PATCH 5/5] =?UTF-8?q?chore(ci):=20.env.production'a=20RYBBIT=5F*?= =?UTF-8?q?=20ve=20EMAIL=5FREPLY=5FTO=20[Bilal=20onay=C4=B1=20gerekir]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sunucu tarafı Rybbit olayları (odeme_tamamlandi, giris_basarili) üç env ister: RYBBIT_HOST, RYBBIT_SITE_ID, RYBBIT_API_KEY. CI'ın yazdığı .env.production'da bunlar yoktu → prod'da sunucuOlayi hep no-op. EMAIL_REPLY_TO isteğe bağlı (yoksa destek@kolaytercih.com). Yalnızca secret ADI referansı eklenir; değerler Gitea secret'larına Bilal tarafından girilmeli. Secret tanımlı değilse satır boş gelir: Rybbit gönderimi no-op kalır, Reply-To varsayılana düşer — zararsız. Deploy hattına dokunur: Bilal onaylamadan merge edilmez. Reddedilirse bu commit tek başına atılabilir (önceki dört commit'e bağımlılığı yok). Co-Authored-By: Claude Fable 5.1 --- .gitea/workflows/ci.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 01f4ab5..16d629c 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -26,11 +26,18 @@ jobs: GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }} RESEND_API_KEY=${{ secrets.RESEND_API_KEY }} EMAIL_FROM=${{ secrets.EMAIL_FROM }} + # İsteğe bağlı: secret yoksa boş gelir → kod destek@kolaytercih.com'a düşer. + EMAIL_REPLY_TO=${{ secrets.EMAIL_REPLY_TO }} IYZICO_API_KEY=${{ secrets.IYZICO_API_KEY }} IYZICO_SECRET_KEY=${{ secrets.IYZICO_SECRET_KEY }} IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }} OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }} OPENROUTER_MODEL=${{ secrets.OPENROUTER_MODEL }} + # Sunucu tarafı Rybbit olayları (odeme_tamamlandi, giris_basarili — + # src/lib/analitik-sunucu.ts). Üçünden biri boşsa gönderim no-op. + RYBBIT_HOST=${{ secrets.RYBBIT_HOST }} + RYBBIT_SITE_ID=${{ secrets.RYBBIT_SITE_ID }} + RYBBIT_API_KEY=${{ secrets.RYBBIT_API_KEY }} EOF # BuildKit'te "docker build --memory" YOK SAYILIR (moby/buildkit#1362);