Add notification quiet hours with digest delivery

Instant notifications (DNS changes, integration failure/recovery
alerts) had no way to avoid pinging overnight. Adds a "Quiet hours"
window under Settings -> Notifications: notifications that would fire
during the window are held in a new notification_queue table instead
of sent immediately, then delivered as one combined digest at the end
time (in the same timezone already used for the daily checks) via a
new scheduled flush job. The scheduled daily checks (secret expiry,
Tailscale key, Docker updates, Proxmox backups) already only fire
once at a chosen time, so this mainly matters for the instant ones.
Includes a live "N queued" indicator with a manual "Flush now" button
for visibility, and correctly falls back to sending immediately
whenever the feature is disabled (the default).

Gating lives at the single choke point every notification already
flows through (notify()), so no per-event-type wiring was needed.

Verified against a fake webhook receiver on an isolated scratch
database: exhaustively checked the midnight-wraparound window math
(9 cases including exact-boundary inclusive/exclusive edges) against
synthetic "now" values rather than depending on when the test
happens to run, then end-to-end through the real notify()/
flushQuietHoursQueue() functions with a window constructed around the
actual current time — confirmed a notification during the window
queues instead of sending, the flush produces one digest with the
original title/message intact and clears the queue, a notification
outside the window sends immediately, and disabling the feature
entirely sends immediately regardless of the window. Confirmed the
real dev database's mtime was untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-22 20:56:33 +02:00
1 parent 99db7e1cf0
commit 73d649377e
12 files changed
+1445 -3

No files matched your search

+52 -1
View File
@@ -1,5 +1,6 @@
import nodemailer from "nodemailer";
import { getSettings, type NotificationEvents } from "./settingsStore.js";
import { enqueueNotification, listQueuedNotifications, clearQueuedNotifications } from "./notificationQueue.js";
// ─── Channel senders (best-effort — failures are logged, never thrown) ──────
@@ -70,10 +71,60 @@ async function sendWebhook(title: string, message: string) {
}
}
export async function notify(title: string, message: string): Promise<void> {
async function sendToChannels(title: string, message: string): Promise<void> {
await Promise.all([sendGotify(title, message), sendNtfy(title, message), sendSmtp(title, message), sendWebhook(title, message)]);
}
function currentTimeInZone(timezone: string): { hh: number; mm: number } {
const parts = new Intl.DateTimeFormat("en-GB", { timeZone: timezone, hour: "2-digit", minute: "2-digit", hour12: false }).formatToParts(
new Date(),
);
return {
hh: Number(parts.find((p) => p.type === "hour")?.value ?? "0"),
mm: Number(parts.find((p) => p.type === "minute")?.value ?? "0"),
};
}
function toMinutes(hhmm: string): number {
const [h, m] = hhmm.split(":").map(Number);
return (Number.isFinite(h) ? h : 0) * 60 + (Number.isFinite(m) ? m : 0);
}
async function isQuietHoursActive(): Promise<boolean> {
const { quietHours, notifications } = await getSettings();
if (!quietHours.enabled) return false;
const { hh, mm } = currentTimeInZone(notifications.timezone);
const nowMin = hh * 60 + mm;
const startMin = toMinutes(quietHours.start);
const endMin = toMinutes(quietHours.end);
if (startMin === endMin) return false; // zero-length window — treat as always off
// A same-day window (e.g. 09:00 -> 17:00) is a simple range; a window that
// wraps past midnight (e.g. 22:00 -> 07:00, the common case) means "active"
// is everything outside [end, start) instead.
return startMin < endMin ? nowMin >= startMin && nowMin < endMin : nowMin >= startMin || nowMin < endMin;
}
export async function notify(title: string, message: string): Promise<void> {
if (await isQuietHoursActive()) {
await enqueueNotification(title, message);
return;
}
await sendToChannels(title, message);
}
/** Called by the daily scheduler at quiet-hours end (or manually) — sends everything queued during the window as one digest, then clears it. */
export async function flushQuietHoursQueue(): Promise<number> {
const queued = await listQueuedNotifications();
if (queued.length === 0) return 0;
const sections = queued.map((q) => `— ${q.title} —\n${q.message}`);
await sendToChannels(
"Homelab Manager — Notifications from quiet hours",
`${queued.length} notification${queued.length !== 1 ? "s were" : " was"} held during quiet hours:\n\n${sections.join("\n\n")}`,
);
await clearQueuedNotifications();
return queued.length;
}
// ─── Test senders — throw on failure so the route can report it ────────────
export async function testGotify(cfg: { url: string; token: string; priority?: number }): Promise<void> {