Alle ausstehenden Aenderungen (Bilder, Texte) fuer den Server-Umzug uebernommen

This commit is contained in:
2026-08-05 18:05:53 +02:00
parent 9af0698056
commit e331965bf1
87 changed files with 8296 additions and 309 deletions
+7 -4
View File
@@ -7,16 +7,19 @@
- Team-Codes: SHA-256-Hash-Lookup (users.code_hash).
- Nach 5 Fehlversuchen pro Client (IP-basiert) 15 Minuten Sperre —
angelehnt an Cloudflares "Protect your login"-Standardregel.
- Sessions: Token wird nur gehasht gespeichert. Inaktivitäts-Timeout
30 Minuten, absolute Höchstdauer 12 Stunden.
- Sessions: Token wird nur gehasht gespeichert. Inaktivitäts-Timeout und
absolute Höchstdauer beide 24 Stunden (Nutzer-Wunsch 04.08.2026: "den
Zugangscode auf der Verwaltungsseite nur einmal am Tag eingeben
müssen") — vorher 30 Min/12 Std, was mitten am Tag zum erneuten
Login-Zwang führte.
===================================================================== */
import { sha256Hex, generateSessionToken, nowIso } from "./crypto.js";
import { logAction } from "./audit.js";
const MAX_ATTEMPTS = 5;
const LOCKOUT_MINUTES = 15;
const INACTIVITY_MS = 30 * 60 * 1000;
const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000;
const INACTIVITY_MS = 24 * 60 * 60 * 1000;
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
export function clientKey(request) {
return request.headers.get("CF-Connecting-IP") || "unknown";
+48
View File
@@ -0,0 +1,48 @@
/* =====================================================================
lib/google-auth.js — Verifiziert ein Google-ID-Token ("Mit Google
anmelden") serverseitig, für den öffentlichen Supporter-Login.
WICHTIG — offener Punkt, siehe README.md "Supporter-Abo Setup":
Braucht GOOGLE_CLIENT_ID (öffentlich, KEIN Geheimnis — steht bewusst als
normale [vars]-Variable in wrangler.toml, nicht als Secret). Ohne
gesetzten Wert bleibt "Mit Google anmelden" ausgeblendet (siehe
supporter.js im Frontend) statt kaputt zu sein.
Verifikation läuft über Googles offiziellen tokeninfo-Endpunkt
(https://oauth2.googleapis.com/tokeninfo) — Google prüft dort Signatur,
Ablaufzeit und Aussteller selbst und gibt die Nutzdaten zurück. Das
spart eine eigene JWKS-/RS256-Prüfung im Worker, ist aber genauso sicher
(offiziell von Google für Server-Side-Verifikation empfohlen).
===================================================================== */
export class GoogleAuthNotConfiguredError extends Error {
constructor() {
super("Google-Login ist noch nicht eingerichtet (GOOGLE_CLIENT_ID fehlt).");
this.name = "GoogleAuthNotConfiguredError";
this.code = "GOOGLE_AUTH_NOT_CONFIGURED";
}
}
/**
* Prüft ein von Google Identity Services im Browser ausgestelltes ID-Token.
* Gibt bei Erfolg die bestätigten Nutzerdaten zurück (E-Mail ist von Google
* selbst bereits verifiziert — braucht deshalb KEINEN zusätzlichen
* E-Mail-Bestätigungscode mehr, siehe routes/supporter.js).
*/
export async function verifyGoogleIdToken(env, credential) {
if (!env.GOOGLE_CLIENT_ID) throw new GoogleAuthNotConfiguredError();
if (!credential) return { ok: false, reason: "Kein Token übergeben." };
const res = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`);
if (!res.ok) return { ok: false, reason: "Google-Token konnte nicht geprüft werden." };
const data = await res.json();
if (data.aud !== env.GOOGLE_CLIENT_ID) return { ok: false, reason: "Token ist für eine andere App ausgestellt." };
if (!data.email || data.email_verified !== "true") return { ok: false, reason: "E-Mail-Adresse ist bei Google nicht bestätigt." };
return {
ok: true,
email: String(data.email).toLowerCase(),
name: data.name || data.given_name || data.email.split("@")[0],
};
}
+9 -1
View File
@@ -2,8 +2,16 @@
http.js — gemeinsame HTTP-Helfer (CORS, JSON-Responses)
===================================================================== */
// TODO (bewusst noch NICHT auf eine feste Origin eingeschränkt): seit
// 03.08.2026 ist die eigene Domain final (dogfather-universe.com, läuft
// PARALLEL zur bisherigen workers.dev-Adresse weiter). Ein Origin-genaues
// CORS bräuchte den `request` (für den Origin-Header) an praktisch jeder
// `json(...)`-Aufrufstelle im gesamten Worker — ein größerer, eigener
// Umbau, absichtlich nicht "schnell nebenbei" gemacht, um keine
// Login-/Zahlungs-Endpunkte durch eine halbfertige Änderung zu brechen.
// Erlaubte Origins für den künftigen Umbau: https://dogfather-universe.com,
// https://www.dogfather-universe.com, https://dogfather-universe.dogfather1608.workers.dev
export function corsHeaders() {
// TODO: sobald die Domain final steht, statt "*" die echte Domain eintragen.
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
+146
View File
@@ -0,0 +1,146 @@
/* =====================================================================
lib/paypal.js — PayPal Subscriptions API Anbindung
(Dogfather_VanVan_Supporter_Abo.odt, Abschnitt 7/8)
WICHTIG — offener Punkt, siehe README.md "Supporter-Abo Setup":
Diese Datei ist vollständig fertig implementiert (OAuth2, Subscription
erstellen, Subscription abfragen, Webhook-Signatur prüfen), kann aber
erst wirklich Geld verarbeiten, sobald folgende Secrets gesetzt sind
(per `wrangler secret put NAME`, NIEMALS im Code/Repo):
PAYPAL_CLIENT_ID
PAYPAL_CLIENT_SECRET
PAYPAL_PLAN_ID (aus dem PayPal-Business-Konto, Abschnitt 7)
PAYPAL_WEBHOOK_ID
Und der Var (in wrangler.toml, unkritisch, kein Secret):
PAYPAL_ENV = "sandbox" | "live"
Diese Werte kann NUR der Website-Betreiber selbst erzeugen (PayPal-
Business-Konto + Developer-App sind an seine eigene Identität/Bank
gebunden) — deshalb "fail closed" mit sprechendem Fehler statt eines
Absturzes, solange sie fehlen.
===================================================================== */
export class PayPalNotConfiguredError extends Error {
constructor() {
super("PayPal ist noch nicht eingerichtet (PAYPAL_CLIENT_ID/SECRET/PLAN_ID fehlen).");
this.name = "PayPalNotConfiguredError";
this.code = "PAYPAL_NOT_CONFIGURED";
}
}
function baseUrl(env) {
return env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
}
function assertConfigured(env) {
if (!env.PAYPAL_CLIENT_ID || !env.PAYPAL_CLIENT_SECRET || !env.PAYPAL_PLAN_ID) {
throw new PayPalNotConfiguredError();
}
}
let cachedToken = null; // { value, expiresAt } — pro Worker-Instanz, spart Requests
async function getAccessToken(env) {
assertConfigured(env);
if (cachedToken && cachedToken.expiresAt > Date.now() + 30_000) return cachedToken.value;
const res = await fetch(`${baseUrl(env)}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${btoa(`${env.PAYPAL_CLIENT_ID}:${env.PAYPAL_CLIENT_SECRET}`)}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
});
if (!res.ok) throw new Error(`PayPal-OAuth fehlgeschlagen (${res.status}): ${await res.text().catch(() => "")}`);
const data = await res.json();
cachedToken = { value: data.access_token, expiresAt: Date.now() + (data.expires_in || 3600) * 1000 };
return cachedToken.value;
}
async function paypalFetch(env, path, options = {}) {
const token = await getAccessToken(env);
const res = await fetch(`${baseUrl(env)}${path}`, {
...options,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(options.headers || {}),
},
});
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) {
const err = new Error(`PayPal-Anfrage fehlgeschlagen (${res.status}): ${text.slice(0, 400)}`);
err.paypalStatus = res.status;
err.paypalBody = data;
throw err;
}
return data;
}
/**
* Erstellt ein PayPal-Abonnement für einen Supporter und liefert den
* "approve"-Link zurück, zu dem der Nutzer weitergeleitet werden muss, um
* das Abo direkt bei PayPal zu bestätigen (Abschnitt 6, Schritt 6-7).
* custom_id trägt unsere interne supporter_id, damit der Webhook später
* weiß, wem die Zahlung gehört.
*/
export async function createSubscription(env, { supporterId, returnUrl, cancelUrl }) {
const data = await paypalFetch(env, "/v1/billing/subscriptions", {
method: "POST",
headers: { "PayPal-Request-Id": `sub-${supporterId}-${Date.now()}` },
body: JSON.stringify({
plan_id: env.PAYPAL_PLAN_ID,
custom_id: supporterId,
application_context: {
brand_name: "Dogfather",
locale: "de-DE",
shipping_preference: "NO_SHIPPING",
user_action: "SUBSCRIBE_NOW",
return_url: returnUrl,
cancel_url: cancelUrl,
},
}),
});
const approveLink = (data.links || []).find((l) => l.rel === "approve");
return { paypalSubscriptionId: data.id, approveUrl: approveLink?.href || null, raw: data };
}
export async function getSubscription(env, paypalSubscriptionId) {
return paypalFetch(env, `/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}`);
}
/** Kündigung (Abschnitt 22: "verständlich und leicht kündbar"). Zugang bleibt bis Periodenende bestehen. */
export async function cancelSubscription(env, paypalSubscriptionId, reason) {
await paypalFetch(env, `/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}/cancel`, {
method: "POST",
body: JSON.stringify({ reason: reason || "Kündigung durch Supporter" }),
});
return true;
}
/**
* Prüft die Echtheit eines eingehenden Webhooks über PayPals offizielle
* Verify-Signature-Funktion (Abschnitt 8, "sichere PayPal-Webhook-Prüfung"
* aus den Sicherheitsanforderungen, Abschnitt 29).
*/
export async function verifyWebhookSignature(env, request, rawBody) {
assertConfigured(env);
if (!env.PAYPAL_WEBHOOK_ID) throw new PayPalNotConfiguredError();
const h = request.headers;
const verification = await paypalFetch(env, "/v1/notifications/verify-webhook-signature", {
method: "POST",
body: JSON.stringify({
auth_algo: h.get("paypal-auth-algo"),
cert_url: h.get("paypal-cert-url"),
transmission_id: h.get("paypal-transmission-id"),
transmission_sig: h.get("paypal-transmission-sig"),
transmission_time: h.get("paypal-transmission-time"),
webhook_id: env.PAYPAL_WEBHOOK_ID,
webhook_event: JSON.parse(rawBody),
}),
});
return verification.verification_status === "SUCCESS";
}
+6
View File
@@ -56,6 +56,11 @@ export const PERMISSIONS = {
SETTINGS_VIEW_STATS: "Statistiken ansehen",
SETTINGS_VIEW_ACTIVITY_LOG: "Aktivitätsverlauf ansehen",
SETTINGS_EDIT_GENERAL: "Allgemeine Einstellungen bearbeiten",
// Supporter-Abo (Dogfather; die Teddy-Kollektion entsteht in Kooperation mit VanVan)
SUPPORTER_VIEW: "Supporter-Abonnenten ansehen",
SUPPORTER_MANAGE_PREMIUMS: "Supporter-Prämien & Versand verwalten",
SUPPORTER_MANAGE_COLLECTIONS: "Supporter-Kollektionen verwalten",
};
export const PERMISSION_GROUPS = [
@@ -65,6 +70,7 @@ export const PERMISSION_GROUPS = [
{ label: "Interne Notizen", keys: ["NOTES_READ", "NOTES_WRITE", "NOTES_EDIT_OWN", "NOTES_EDIT_OTHERS", "NOTES_DELETE"] },
{ label: "Teamverwaltung", keys: ["TEAM_VIEW", "TEAM_PREPARE_USER", "TEAM_LOCK_USER", "TEAM_ASSIGN_ROLE", "TEAM_CREATE_ROLE", "TEAM_EDIT_PERMISSIONS"] },
{ label: "Einstellungen", keys: ["SETTINGS_EDIT_FORMS", "SETTINGS_EDIT_STATUS_TYPES", "SETTINGS_VIEW_STATS", "SETTINGS_VIEW_ACTIVITY_LOG", "SETTINGS_EDIT_GENERAL"] },
{ label: "DogiCrew 🩵", keys: ["SUPPORTER_VIEW", "SUPPORTER_MANAGE_PREMIUMS", "SUPPORTER_MANAGE_COLLECTIONS"] },
];
// Nur zur Dokumentation/Klarheit — diese Aktionen sind NIE als Permission
+108
View File
@@ -0,0 +1,108 @@
/* =====================================================================
lib/supporter-auth.js — Registrierung, E-Mail-Bestätigung, Magic-Link-
Login und Sessions für den ÖFFENTLICHEN Supporter-Bereich.
Bewusst komplett getrennt von auth.js (das ist für das interne
Team-Zugangssystem mit Rollen/Rechten). Supporter sind Fans/Kund:innen
ohne jegliche Berechtigungen im internen Bereich.
Login-Prinzip (Abschnitt 5/116 im Auftrag: "bevorzugt … sicherer
E-Mail-Code oder Magic Link"): kein Passwort nötig. Ein 6-stelliger Code
wird an die hinterlegte E-Mail-Adresse geschickt, 15 Minuten gültig,
einmal verwendbar, nur der Hash liegt in der Datenbank.
===================================================================== */
import { sha256Hex, generateSessionToken, generateId, nowIso } from "./crypto.js";
const CODE_TTL_MS = 15 * 60 * 1000;
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 Tage — Supporter sollen nicht ständig neu einloggen müssen
const INACTIVITY_MS = 14 * 24 * 60 * 60 * 1000; // 14 Tage ohne Aktivität → Session verfällt
export function normalizeTikTokUsername(input) {
if (!input) return null;
return String(input).trim().replace(/^@+/, "").toLowerCase() || null;
}
export function normalizeEmail(input) {
return String(input || "").trim().toLowerCase();
}
function generateNumericCode() {
const n = crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000;
return String(n).padStart(6, "0");
}
/** Erzeugt+speichert einen neuen 6-stelligen Code für E-Mail-Bestätigung oder Login. */
export async function issueAuthCode(env, supporterId, purpose) {
const code = generateNumericCode();
const codeHash = await sha256Hex(code);
await env.DB.prepare(
`INSERT INTO supporter_auth_codes (id, supporter_id, purpose, code_hash, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?)`
)
.bind(generateId(), supporterId, purpose, codeHash, new Date(Date.now() + CODE_TTL_MS).toISOString(), nowIso())
.run();
return code;
}
/** Prüft einen eingegebenen Code; markiert ihn bei Erfolg als verbraucht (einmal gültig). */
export async function verifyAuthCode(env, supporterId, purpose, code) {
const codeHash = await sha256Hex(String(code || "").trim());
const row = await env.DB.prepare(
`SELECT id, expires_at, used_at FROM supporter_auth_codes
WHERE supporter_id = ? AND purpose = ? AND code_hash = ?
ORDER BY created_at DESC LIMIT 1`
)
.bind(supporterId, purpose, codeHash)
.first();
if (!row) return false;
if (row.used_at) return false;
if (new Date(row.expires_at).getTime() < Date.now()) return false;
await env.DB.prepare(`UPDATE supporter_auth_codes SET used_at = ? WHERE id = ?`).bind(nowIso(), row.id).run();
return true;
}
export async function createSupporterSession(env, supporterId) {
const token = generateSessionToken();
const tokenHash = await sha256Hex(token);
const now = new Date();
await env.DB.prepare(
`INSERT INTO supporter_sessions (token_hash, supporter_id, created_at, last_active_at, expires_at)
VALUES (?, ?, ?, ?, ?)`
)
.bind(tokenHash, supporterId, now.toISOString(), now.toISOString(), new Date(now.getTime() + SESSION_MAX_AGE_MS).toISOString())
.run();
return token;
}
export async function resolveSupporterSession(env, token) {
if (!token) return null;
const tokenHash = await sha256Hex(token);
const session = await env.DB.prepare(`SELECT * FROM supporter_sessions WHERE token_hash = ?`).bind(tokenHash).first();
if (!session) return null;
const now = Date.now();
if (new Date(session.expires_at).getTime() < now || now - new Date(session.last_active_at).getTime() > INACTIVITY_MS) {
await env.DB.prepare(`DELETE FROM supporter_sessions WHERE token_hash = ?`).bind(tokenHash).run();
return null;
}
await env.DB.prepare(`UPDATE supporter_sessions SET last_active_at = ? WHERE token_hash = ?`).bind(nowIso(), tokenHash).run();
const supporter = await env.DB.prepare(
`SELECT * FROM supporter_users WHERE id = ? AND status = 'active'`
)
.bind(session.supporter_id)
.first();
if (!supporter) return null;
return supporter;
}
export async function endSupporterSession(env, token) {
if (!token) return;
await env.DB.prepare(`DELETE FROM supporter_sessions WHERE token_hash = ?`).bind(await sha256Hex(token)).run();
}
export async function requireSupporterSession(request, env) {
const auth = request.headers.get("Authorization") || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
return resolveSupporterSession(env, token);
}
@@ -0,0 +1,98 @@
/* =====================================================================
lib/supporter-cycle.js — Reine Rechenlogik für den 30-Monats-
Prämienzyklus des Dogfather Supporter-Abos (die Teddy-Kollektion
entsteht in Kooperation mit VanVan, das Abo selbst gehört nur Dogfather).
Verbindliche Regeln (siehe Dogfather_VanVan_Supporter_Abo.odt,
Abschnitt 11-13, wortwörtlich umgesetzt, NICHT verändert):
Monat 6 → kleiner Hasen-Teddy
Monat 12 → mittelgroßer Hasen-Teddy
Monat 18 → großer Hasen-Teddy
Monat 24 → Tasse + Autogrammkarte
Monat 30 → Hoodie (Zyklus damit abgeschlossen)
ab 31 → neuer Zyklus, neue Kollektion, Fortschritt bei 0
Reine Funktionen ohne D1-/Netzwerkzugriff — dadurch leicht von Hand
nachrechenbar und ohne Testrunner überprüfbar (siehe Beispiel im
Auftrag: 37 bezahlte Monate → Zyklus 2, Fortschritt 7 von 30).
===================================================================== */
export const CYCLE_LENGTH = 30;
// Reihenfolge ist bewusst fest — bestimmt auch premium_type-Zuordnung.
export const MILESTONES = [
{ month: 6, type: "teddy_klein", labelDe: "Kleiner Hasen-Teddy" },
{ month: 12, type: "teddy_mittel", labelDe: "Mittelgroßer Hasen-Teddy" },
{ month: 18, type: "teddy_gross", labelDe: "Großer Hasen-Teddy" },
{ month: 24, type: "tasse_karte", labelDe: "Tasse & Autogrammkarte" },
{ month: 30, type: "hoodie", labelDe: "Exklusiver Hoodie" },
];
/**
* Aus der GESAMTEN Anzahl erfolgreich angerechneter Abo-Monate (bleibt für
* immer erhalten, auch über Kündigung/Reaktivierung hinweg) errechnet sich
* rein rechnerisch: wie viele 30er-Zyklen sind vollständig abgeschlossen,
* in welchem Zyklus befindet man sich gerade (1-basiert) und wie weit ist
* der aktuelle Zyklus fortgeschritten (1..30).
*/
export function computeProgress(totalPaidMonths) {
const total = Math.max(0, Math.floor(totalPaidMonths || 0));
if (total === 0) {
return { totalPaidMonths: 0, completedCycles: 0, cycleNumber: 1, currentCycleMonth: 0 };
}
const completedCycles = Math.floor((total - 1) / CYCLE_LENGTH);
const currentCycleMonth = total - completedCycles * CYCLE_LENGTH; // 1..30
return {
totalPaidMonths: total,
completedCycles,
cycleNumber: completedCycles + 1, // 1-basiert = welche Kollektion gerade läuft
currentCycleMonth,
};
}
/** Welche Meilensteine sind im AKTUELLEN, laufenden Zyklus schon erreicht? */
export function reachedMilestonesInCurrentCycle(currentCycleMonth) {
return MILESTONES.filter((m) => currentCycleMonth >= m.month);
}
/** Nächster noch offener Meilenstein im laufenden Zyklus (oder null, wenn Zyklus fertig). */
export function nextMilestone(currentCycleMonth) {
return MILESTONES.find((m) => currentCycleMonth < m.month) || null;
}
/**
* Liefert ALLE (Zyklus, Meilenstein)-Kombinationen, die bei der gegebenen
* Gesamtmonatszahl freigeschaltet sein MÜSSEN — über alle bereits
* abgeschlossenen Zyklen hinweg plus den laufenden Zyklus. Wird beim
* Verarbeiten einer erfolgreichen Zahlung benutzt, um herauszufinden,
* welche supporter_premiums-Zeilen neu angelegt werden müssen.
*/
export function allUnlockedMilestones(totalPaidMonths) {
const { completedCycles, currentCycleMonth } = computeProgress(totalPaidMonths);
const unlocked = [];
// Vollständig abgeschlossene Zyklen: ALLE 5 Meilensteine gelten als erreicht.
for (let c = 1; c <= completedCycles; c++) {
for (const m of MILESTONES) unlocked.push({ cycleNumber: c, ...m });
}
// Laufender Zyklus: nur die bereits erreichten.
const cycleNumber = completedCycles + 1;
for (const m of reachedMilestonesInCurrentCycle(currentCycleMonth)) {
unlocked.push({ cycleNumber, ...m });
}
return unlocked;
}
/** Für die Fortschritts-Zeitleiste im Supporter-Bereich (Abschnitt 10, Fenster 2). */
export function timelineForCurrentCycle(totalPaidMonths) {
const { currentCycleMonth, cycleNumber } = computeProgress(totalPaidMonths);
return {
cycleNumber,
currentCycleMonth,
milestones: MILESTONES.map((m) => ({
...m,
reached: currentCycleMonth >= m.month,
isNext: nextMilestone(currentCycleMonth)?.month === m.month,
monthsRemaining: Math.max(0, m.month - currentCycleMonth),
})),
};
}
@@ -0,0 +1,92 @@
/* =====================================================================
lib/supporter-mail.js — Transaktions-E-Mails für den Supporter-Bereich
(E-Mail-Bestätigung, Login-Codes, Zahlungs-/Prämien-Benachrichtigungen).
WICHTIG — offener Punkt, siehe auch README.md "Supporter-Abo Setup":
Es existiert aktuell KEIN E-Mail-Versand-Anbieter (kein Resend/
MailChannels o.ä. eingerichtet) und KEINE eigene Domain (die Seite läuft
noch auf *.workers.dev) — beides wird für zuverlässigen E-Mail-Versand
gebraucht (Absender-Domain muss per SPF/DKIM verifiziert sein, sonst
landet alles im Spam oder wird abgelehnt).
Diese Datei ist deshalb bewusst als AUSTAUSCHBARER Adapter gebaut:
Sobald RESEND_API_KEY (+ RESEND_FROM) als Secret gesetzt ist, wird über
die Resend-API (https://resend.com, kostenloser Tier reicht für den
Start) verschickt. Fehlt der Key, wird NICHTS verschickt und ein
sprechender Fehler zurückgegeben — bewusst "fail closed" statt den Code
irgendwo im Klartext zu loggen (Sicherheitsprinzip wie beim Rest des
Zugangssystems, siehe audit.js).
===================================================================== */
export class EmailNotConfiguredError extends Error {
constructor() {
super("E-Mail-Versand ist noch nicht eingerichtet (RESEND_API_KEY fehlt).");
this.name = "EmailNotConfiguredError";
this.code = "EMAIL_NOT_CONFIGURED";
}
}
async function send(env, { to, subject, html }) {
if (!env.RESEND_API_KEY || !env.RESEND_FROM) throw new EmailNotConfiguredError();
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${env.RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ from: env.RESEND_FROM, to: [to], subject, html }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`E-Mail-Versand fehlgeschlagen (${res.status}): ${text.slice(0, 300)}`);
}
return true;
}
const WRAP = (title, bodyHtml) => `
<div style="font-family:Arial,sans-serif;background:#0b0d10;color:#f3f5f7;padding:32px;">
<div style="max-width:480px;margin:0 auto;background:#171b21;border-radius:14px;padding:28px;border:1px solid #262c35;">
<h1 style="color:#8fd9ea;font-size:20px;margin:0 0 16px;">${title}</h1>
${bodyHtml}
<p style="color:#9aa4b2;font-size:12px;margin-top:24px;">DogiCrew 🩵</p>
</div>
</div>`;
export async function sendVerifyEmailCode(env, { to, name, code }) {
return send(env, {
to,
subject: "Bestätige deine E-Mail-Adresse — DogiCrew 🩵",
html: WRAP(
`Willkommen, ${name}! 🩵`,
`<p style="line-height:1.6;">Bestätige deine E-Mail-Adresse mit diesem Code:</p>
<p style="font-size:32px;font-weight:800;letter-spacing:4px;color:#fff;">${code}</p>
<p style="color:#9aa4b2;font-size:13px;">Der Code ist 15 Minuten gültig.</p>`
),
});
}
export async function sendLoginCode(env, { to, name, code }) {
return send(env, {
to,
subject: "Dein Login-Code — Dogfather Supporter-Bereich",
html: WRAP(
`Hallo ${name} 👋`,
`<p style="line-height:1.6;">Dein Login-Code für den Supporter-Bereich:</p>
<p style="font-size:32px;font-weight:800;letter-spacing:4px;color:#fff;">${code}</p>
<p style="color:#9aa4b2;font-size:13px;">Der Code ist 15 Minuten gültig. Hast du das nicht angefordert, ignoriere diese E-Mail.</p>`
),
});
}
export async function sendMilestoneUnlocked(env, { to, name, premiumLabel }) {
return send(env, {
to,
subject: `Neuer Meilenstein freigeschaltet — ${premiumLabel} 🎉`,
html: WRAP(
`Glückwunsch, ${name}! 🩵`,
`<p style="line-height:1.6;">Du hast einen neuen Meilenstein erreicht und kannst jetzt deine Prämie einlösen:</p>
<p style="font-size:20px;font-weight:800;color:#fff;">${premiumLabel}</p>
<p style="color:#9aa4b2;font-size:13px;">Logge dich in deinen Supporter-Bereich ein, um die Prämie einzulösen.</p>`
),
});
}