From f49ac179223f693ba03efc88029a564ab26b2049 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 1 Sep 2026 20:24:45 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Benachrichtigungen=20=C3=BCber=20E-Mail?= =?UTF-8?q?,=20ntfy,=20Gotify=20und=20Webhook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vier Wege, ein Nachrichtenformat. Der Webhook trägt sowohl content als auch text im Rumpf — damit bedient derselbe Kanal Discord, Slack und Mattermost, ohne dass jemand ein Format auswählen muss. Auch ein Fehlversuch wird protokolliert: sonst versucht es jeder nächste Lauf erneut und der Kanal wird zugespammt, sobald er wieder erreichbar ist. Die E-Mail-Vorlage ist schlichtes HTML mit Inline-Stilen — Mailprogramme können weder externe Stylesheets noch CSS-Variablen. Das Logo steckt als Inline-SVG drin. --- src/lib/notify.ts | 283 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 src/lib/notify.ts diff --git a/src/lib/notify.ts b/src/lib/notify.ts new file mode 100644 index 0000000..6b52ec8 --- /dev/null +++ b/src/lib/notify.ts @@ -0,0 +1,283 @@ +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()}`, + }, + ); +}