Files
flimmer/src/lib/notify.ts
T
Florian Wilde f49ac17922 feat: Benachrichtigungen über E-Mail, ntfy, Gotify und Webhook
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.
2026-09-01 20:24:45 +02:00

284 lines
10 KiB
TypeScript

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:<id>". */
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return `<!doctype html>
<html lang="de"><body style="margin:0;background:#0c0a14;padding:24px;font-family:'Segoe UI',Inter,Helvetica,Arial,sans-serif;color:#f4f1fa">
<table role="presentation" cellpadding="0" cellspacing="0" style="max-width:520px;margin:0 auto;background:#171424;border:1px solid #2e2745;border-radius:16px">
<tr><td style="padding:24px 24px 8px">
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
<td style="padding-right:10px">
<svg width="30" height="30" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="g" x1="20" y1="24" x2="108" y2="102" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#ffc46b"/><stop offset=".38" stop-color="#ffb454"/><stop offset="1" stop-color="#ff4d9d"/>
</linearGradient></defs>
<rect x="22" y="26" width="84" height="64" rx="18" fill="none" stroke="url(#g)" stroke-width="7"/>
<path d="M55.5 45.5 L80 58 L55.5 70.5 Z" fill="url(#g)" stroke="url(#g)" stroke-width="6.5" stroke-linejoin="round"/>
<path d="M64 90 V101 M49 105.5 H79" stroke="url(#g)" stroke-width="6.5" stroke-linecap="round" stroke-opacity=".62"/>
</svg>
</td>
<td style="font-size:20px;font-weight:700;letter-spacing:-.01em;color:#ffb454">Flimmer</td>
</tr></table>
</td></tr>
<tr><td style="padding:8px 24px 4px;font-size:18px;font-weight:600">${escape(message.subject)}</td></tr>
<tr><td style="padding:4px 24px 20px;font-size:15px;line-height:1.55;color:#aca5c2">${escape(message.body).replace(/\n/g, "<br>")}</td></tr>
${
link
? `<tr><td style="padding:0 24px 24px">
<a href="${link}" style="display:inline-block;background:#ffb454;color:#1a1024;text-decoration:none;font-weight:600;padding:10px 18px;border-radius:10px">In Flimmer ansehen</a>
</td></tr>`
: ""
}
<tr><td style="padding:0 24px 22px;font-size:12px;color:#7c7594">
Diese Nachricht kommt von deiner eigenen Flimmer-Instanz. Kanäle verwaltest du in den Einstellungen.
</td></tr>
</table>
</body></html>`;
}
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<string, string> = {
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<void> {
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<NotifyResult> {
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<void> {
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()}`,
},
);
}