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>
This commit is contained in:
1 parent
3a53a86ce0
commit
3255314402
16 files changed
+1272
-23
No files matched your search
@@ -0,0 +1,179 @@
|
||||
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")}`,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user