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.
This commit is contained in:
@@ -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:<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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
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()}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user