Merge branch 'gece/p4-eposta' into gece/entegrasyon-test
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 · 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> · <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"),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user