merge: origin/gece/p4-eposta (gece vardiyası 2026-09-21)

This commit is contained in:
bilalgursen
2026-09-21 16:10:37 +03:00
5 changed files with 155 additions and 17 deletions

View File

@@ -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);

View File

@@ -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,

View File

@@ -2,10 +2,11 @@ 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";
import { epostaGonder } from "./eposta-gonder";
import { epostaGonder, epostaKonsolModu } from "./eposta-gonder";
/**
* Verify URL'ini maile konacak onay sayfası URL'ine çevirir. Doğrulama linki
@@ -22,7 +23,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<string, number> {
const g = globalThis as { __girisEpostaSon?: Map<string, number> };
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
@@ -30,13 +91,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));
}
@@ -95,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",
},
});
},
},
},
},
});

View File

@@ -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<void> {
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 <onboarding@resend.dev>",
from: gonderen || "KolayTercih <onboarding@resend.dev>",
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

View File

@@ -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) <link> ile progresif olarak
* yüklenir; desteklemeyen istemciler sistem yazı tipine düşer.
* Harici font <link>'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({
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
<title>${kacir(baslikEtiketi)}</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,300;12..96,600&family=Cal+Sans&display=swap" rel="stylesheet">
</head>
<body style="margin:0;padding:0;background-color:${ZEMIN};">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;font-size:1px;line-height:1px;">${onizleme}</div>
@@ -96,7 +100,8 @@ function kabuk({
<tr>
<td style="padding:20px 8px 0;font-family:${YAZI_TIPI};font-size:12px;line-height:1.6;color:#94a3b8;">
KolayTercih &middot; YKS tercih listeni dakikalar içinde hazırla<br>
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.<br>
<a href="https://${SITE_ALANI}" style="color:#94a3b8;">${SITE_ALANI}</a> &middot; <a href="mailto:${DESTEK_EPOSTA}" style="color:#94a3b8;">${DESTEK_EPOSTA}</a>
</td>
</tr>
</table>
@@ -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"),
};
}