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 = { "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 = { "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 { 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 { 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 { const base = cfg.url.replace(/\/$/, ""); const headers: Record = { "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 { 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 { const headers: Record = { "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 { const { notifications } = await getSettings(); return notifications[key] !== false; } export async function notifyDnsRecordAdded( providerLabel: string, zoneName: string, record: { type: string; name: string; content: string }, ): Promise { 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 { 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 { 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 { 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")}`, ); }