import "server-only"; import nodemailer from "nodemailer"; import { prisma } from "./db"; import { env } from "./env"; import type { ChannelKind } from "@/generated/prisma/client"; /** * Benachrichtigungen. * * Vier Wege, ein Nachrichtenformat: E-Mail, ntfy, Gotify und ein generischer * Webhook (der auch Discord und Slack bedient). Jeder Nutzer richtet sich * seine Kanäle selbst ein — der Betreiber gibt nur den SMTP-Zugang vor. * * Doppelversand verhindert `NotificationLog` über einen Dedupe-Schlüssel: * dieselbe Episode wird nie zweimal gemeldet, egal wie oft der Abgleich läuft. */ export type NotificationMessage = { /** Kurze Betreffzeile. */ subject: string; /** Fließtext, eine bis drei Zeilen. */ body: string; /** Ziel in Flimmer, z. B. „/titel/abc". */ path?: string; /** Verhindert Doppelversand, z. B. „episode:". */ dedupeKey: string; }; export type ChannelConfig = { /** ntfy: Server-URL, Standard https://ntfy.sh */ server?: string; /** ntfy: Topic; Gotify/Webhook: nicht benutzt */ topic?: string; /** Gotify: Server-URL */ url?: string; /** Gotify: Anwendungs-Token; ntfy: optionaler Zugriffstoken */ token?: string; /** Webhook: Zieladresse */ webhookUrl?: string; /** E-Mail: abweichende Empfängeradresse */ email?: string; }; // --------------------------------------------------------------------------- // Versandwege // --------------------------------------------------------------------------- function linkFor(message: NotificationMessage): string | null { return message.path ? `${env.appUrl}${message.path}` : null; } async function sendEmail(to: string, message: NotificationMessage) { if (!env.smtp.isConfigured) { throw new Error("Für diese Instanz ist kein SMTP-Zugang eingerichtet."); } const transport = nodemailer.createTransport({ host: env.smtp.host, port: env.smtp.port, secure: env.smtp.secure, ...(env.smtp.user ? { auth: { user: env.smtp.user, pass: env.smtp.password } } : {}), }); const link = linkFor(message); await transport.sendMail({ from: env.smtp.from, to, subject: `${message.subject} · ${env.appName}`, text: [message.body, link].filter(Boolean).join("\n\n"), html: emailTemplate(message, link), }); } /** * E-Mail-Vorlage im Flimmer-Look. * * Bewusst schlichtes HTML mit Inline-Stilen — E-Mail-Programme können weder * externe Stylesheets noch CSS-Variablen. Das Logo steckt als Inline-SVG * drin, damit nichts nachgeladen werden muss. */ function emailTemplate(message: NotificationMessage, link: string | null): string { const escape = (value: string) => value.replace(/&/g, "&").replace(//g, ">"); return ` ${ link ? `` : "" }
Flimmer
${escape(message.subject)}
${escape(message.body).replace(/\n/g, "
")}
In Flimmer ansehen
Diese Nachricht kommt von deiner eigenen Flimmer-Instanz. Kanäle verwaltest du in den Einstellungen.
`; } async function sendNtfy(config: ChannelConfig, message: NotificationMessage) { const server = (config.server || "https://ntfy.sh").replace(/\/+$/, ""); if (!config.topic) throw new Error("Für ntfy fehlt das Topic."); const link = linkFor(message); const headers: Record = { Title: message.subject, Tags: "clapper", }; if (link) headers.Click = link; if (config.token) headers.Authorization = `Bearer ${config.token}`; const response = await fetch(`${server}/${encodeURIComponent(config.topic)}`, { method: "POST", headers, body: message.body, }); if (!response.ok) throw new Error(`ntfy antwortete mit ${response.status}.`); } async function sendGotify(config: ChannelConfig, message: NotificationMessage) { if (!config.url || !config.token) throw new Error("Für Gotify fehlen Adresse oder Token."); const base = config.url.replace(/\/+$/, ""); const link = linkFor(message); const response = await fetch(`${base}/message?token=${encodeURIComponent(config.token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ title: message.subject, message: [message.body, link].filter(Boolean).join("\n\n"), priority: 4, }), }); if (!response.ok) throw new Error(`Gotify antwortete mit ${response.status}.`); } /** * Generischer Webhook. * * Der Rumpf trägt sowohl `content` (Discord) als auch `text` (Slack und * Mattermost) und daneben die Einzelteile — damit funktioniert derselbe Kanal * für alle drei, ohne dass jemand ein Format auswählen muss. */ async function sendWebhook(config: ChannelConfig, message: NotificationMessage) { if (!config.webhookUrl) throw new Error("Für den Webhook fehlt die Adresse."); const link = linkFor(message); const text = [`**${message.subject}**`, message.body, link].filter(Boolean).join("\n"); const response = await fetch(config.webhookUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ content: text, text, username: env.appName, subject: message.subject, body: message.body, url: link, }), }); if (!response.ok) throw new Error(`Der Webhook antwortete mit ${response.status}.`); } // --------------------------------------------------------------------------- // Versand // --------------------------------------------------------------------------- export async function sendThroughChannel( kind: ChannelKind, config: ChannelConfig, fallbackEmail: string, message: NotificationMessage, ): Promise { switch (kind) { case "EMAIL": return sendEmail(config.email || fallbackEmail, message); case "NTFY": return sendNtfy(config, message); case "GOTIFY": return sendGotify(config, message); case "WEBHOOK": return sendWebhook(config, message); } } export type NotifyResult = { sent: number; skipped: number; failed: number }; /** * Schickt eine Nachricht über alle aktiven Kanäle eines Nutzers. * Bereits gemeldete Nachrichten (gleicher Dedupe-Schlüssel) werden übersprungen. */ export async function notifyUser( userId: string, message: NotificationMessage, ): Promise { const existing = await prisma.notificationLog.findUnique({ where: { userId_dedupeKey: { userId, dedupeKey: message.dedupeKey } }, select: { id: true }, }); if (existing) return { sent: 0, skipped: 1, failed: 0 }; const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true, channels: { where: { enabled: true } } }, }); if (!user || user.channels.length === 0) return { sent: 0, skipped: 1, failed: 0 }; let sent = 0; let failed = 0; const errors: string[] = []; for (const channel of user.channels) { try { await sendThroughChannel( channel.kind, (channel.config ?? {}) as ChannelConfig, user.email, message, ); sent += 1; } catch (error) { failed += 1; errors.push(`${channel.name}: ${error instanceof Error ? error.message : "unbekannt"}`); } } // Auch fehlgeschlagene Versuche werden protokolliert — sonst versucht es der // nächste Lauf endlos erneut und der Kanal wird zugespammt, sobald er wieder // erreichbar ist. await prisma.notificationLog.create({ data: { userId, dedupeKey: message.dedupeKey, subject: message.subject, body: message.body, ok: failed === 0, error: errors.length > 0 ? errors.join(" | ") : null, }, }); return { sent, skipped: 0, failed }; } /** Testnachricht für die Einstellungsseite. */ export async function sendTestNotification(userId: string, channelId: string): Promise { const channel = await prisma.notificationChannel.findFirst({ where: { id: channelId, userId }, include: { user: { select: { email: true, name: true } } }, }); if (!channel) throw new Error("Diesen Kanal gibt es nicht."); await sendThroughChannel( channel.kind, (channel.config ?? {}) as ChannelConfig, channel.user.email, { subject: "Testnachricht von Flimmer", body: `Hallo ${channel.user.name}, dieser Kanal funktioniert. Ab jetzt meldet sich Flimmer hier, wenn eine neue Episode ansteht.`, path: "/einstellungen/benachrichtigungen", dedupeKey: `test:${channelId}:${Date.now()}`, }, ); }