Files
Homelab-manager/server/src/services/notify.ts
T
bobbanandClaude Sonnet 5 3255314402 Build the Settings module: notification channels, event toggles, DNS badge colors
The Settings page was a "coming soon" placeholder. Port Sloth Manager's
settings feature set: Gotify/ntfy/SMTP/webhook notification channels
(each with its own test-send button), per-event toggles (DNS record
added/updated/deleted, a daily secret-expiry digest with configurable
time/timezone), and per-provider DNS badge color customization.

Settings persist in the existing `settings` key/value table via a new
settingsStore service; a notify service fans a message out to every
enabled channel. DNS record add/update/delete now fire notifications,
and a node-schedule job re-arms itself whenever the notification
settings change. Removed the now-superseded GOTIFY_URL/GOTIFY_TOKEN
env vars in favor of in-app configuration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 12:14:05 +02:00

180 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import nodemailer from "nodemailer";
import { getSettings, type NotificationEvents } from "./settingsStore.js";
// ─── Channel senders (best-effort — failures are logged, never thrown) ──────
async function sendGotify(title: string, message: string) {
const { gotify } = await getSettings();
if (!gotify.enabled || !gotify.url || !gotify.token) return;
const base = gotify.url.replace(/\/$/, "");
try {
const res = await fetch(`${base}/message?token=${encodeURIComponent(gotify.token)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, message, priority: gotify.priority ?? 5 }),
});
if (!res.ok) console.error(`[notify] Gotify error ${res.status}: ${await res.text().catch(() => "")}`);
} catch (err) {
console.error("[notify] Gotify failed:", err instanceof Error ? err.message : err);
}
}
async function sendNtfy(title: string, message: string) {
const { ntfy } = await getSettings();
if (!ntfy.enabled || !ntfy.url || !ntfy.topic) return;
const base = ntfy.url.replace(/\/$/, "");
const headers: Record<string, string> = {
"Content-Type": "text/plain",
Title: title,
Priority: String(ntfy.priority ?? 3),
};
if (ntfy.token) headers.Authorization = `Bearer ${ntfy.token}`;
try {
const res = await fetch(`${base}/${encodeURIComponent(ntfy.topic)}`, { method: "POST", headers, body: message });
if (!res.ok) console.error(`[notify] ntfy error ${res.status}: ${await res.text().catch(() => "")}`);
} catch (err) {
console.error("[notify] ntfy failed:", err instanceof Error ? err.message : err);
}
}
async function sendSmtp(title: string, message: string) {
const { smtp } = await getSettings();
if (!smtp.enabled || !smtp.host || !smtp.to || !smtp.from) return;
try {
const transporter = nodemailer.createTransport({
host: smtp.host,
port: Number(smtp.port) || 587,
secure: !!smtp.secure,
auth: smtp.username ? { user: smtp.username, pass: smtp.password } : undefined,
});
await transporter.sendMail({ from: smtp.from, to: smtp.to, subject: title, text: message });
} catch (err) {
console.error("[notify] SMTP failed:", err instanceof Error ? err.message : err);
}
}
async function sendWebhook(title: string, message: string) {
const { webhook } = await getSettings();
if (!webhook.enabled || !webhook.url) return;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (webhook.secret) headers["X-Webhook-Secret"] = webhook.secret;
try {
const res = await fetch(webhook.url, {
method: "POST",
headers,
body: JSON.stringify({ content: `**${title}**\n${message}` }),
});
if (!res.ok) console.error(`[notify] Webhook error ${res.status}: ${await res.text().catch(() => "")}`);
} catch (err) {
console.error("[notify] Webhook failed:", err instanceof Error ? err.message : err);
}
}
export async function notify(title: string, message: string): Promise<void> {
await Promise.all([sendGotify(title, message), sendNtfy(title, message), sendSmtp(title, message), sendWebhook(title, message)]);
}
// ─── Test senders — throw on failure so the route can report it ────────────
export async function testGotify(cfg: { url: string; token: string; priority?: number }): Promise<void> {
const base = cfg.url.replace(/\/$/, "");
const res = await fetch(`${base}/message?token=${encodeURIComponent(cfg.token)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: "Homelab Manager — Test",
message: "Gotify notifications are working correctly.",
priority: cfg.priority ?? 5,
}),
});
if (!res.ok) throw new Error(`Gotify returned ${res.status}: ${await res.text().catch(() => "")}`);
}
export async function testNtfy(cfg: { url: string; topic: string; token?: string; priority?: number }): Promise<void> {
const base = cfg.url.replace(/\/$/, "");
const headers: Record<string, string> = { "Content-Type": "text/plain", Title: "Homelab Manager — Test", Priority: String(cfg.priority ?? 3) };
if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`;
const res = await fetch(`${base}/${encodeURIComponent(cfg.topic)}`, {
method: "POST",
headers,
body: "ntfy notifications are working correctly.",
});
if (!res.ok) throw new Error(`ntfy returned ${res.status}: ${await res.text().catch(() => "")}`);
}
export async function testSmtp(cfg: {
host: string;
port?: number;
secure?: boolean;
username?: string;
password?: string;
from: string;
to: string;
}): Promise<void> {
const transporter = nodemailer.createTransport({
host: cfg.host,
port: Number(cfg.port) || 587,
secure: !!cfg.secure,
auth: cfg.username ? { user: cfg.username, pass: cfg.password } : undefined,
});
await transporter.verify();
await transporter.sendMail({ from: cfg.from, to: cfg.to, subject: "Homelab Manager — Test", text: "SMTP notifications are working correctly." });
}
export async function testWebhook(cfg: { url: string; secret?: string }): Promise<void> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (cfg.secret) headers["X-Webhook-Secret"] = cfg.secret;
const res = await fetch(cfg.url, {
method: "POST",
headers,
body: JSON.stringify({ content: "**Homelab Manager — Test**\nWebhook notifications are working correctly." }),
});
if (!res.ok) throw new Error(`Webhook returned ${res.status}: ${await res.text().catch(() => "")}`);
}
// ─── Event helpers ───────────────────────────────────────────────────────────
async function eventEnabled(key: keyof NotificationEvents): Promise<boolean> {
const { notifications } = await getSettings();
return notifications[key] !== false;
}
export async function notifyDnsRecordAdded(
providerLabel: string,
zoneName: string,
record: { type: string; name: string; content: string },
): Promise<void> {
if (!(await eventEnabled("dnsAdd"))) return;
await notify("DNS Record Added", `[${providerLabel}] ${zoneName}\n+ ${record.type} ${record.name} → ${record.content}`);
}
export async function notifyDnsRecordUpdated(
providerLabel: string,
zoneName: string,
record: { type: string; name: string; content: string },
): Promise<void> {
if (!(await eventEnabled("dnsUpdate"))) return;
await notify("DNS Record Updated", `[${providerLabel}] ${zoneName}\n✎ ${record.type} ${record.name} → ${record.content}`);
}
export async function notifyDnsRecordDeleted(
providerLabel: string,
zoneName: string,
record: { type: string; name: string; content: string },
): Promise<void> {
if (!(await eventEnabled("dnsDelete"))) return;
await notify("DNS Record Deleted", `[${providerLabel}] ${zoneName}\n− ${record.type} ${record.name} ${record.content}`);
}
export async function notifySecretExpiry(
expiring: { name: string; status: "expired" | "expiring"; daysLeft: number }[],
): Promise<void> {
if (expiring.length === 0) return;
if (!(await eventEnabled("secretCheck"))) return;
const lines = expiring.map((s) => (s.status === "expired" ? `✕ EXPIRED — ${s.name}` : `⚠ ${s.daysLeft}d left — ${s.name}`));
await notify(
"Homelab Manager — Secrets Alert",
`${expiring.length} secret${expiring.length !== 1 ? "s" : ""} need attention:\n\n${lines.join("\n")}`,
);
}