diff --git a/web/app/api/inngest/route.ts b/web/app/api/inngest/route.ts index c197d81f..e1e47380 100644 --- a/web/app/api/inngest/route.ts +++ b/web/app/api/inngest/route.ts @@ -12,6 +12,7 @@ import { expiredDeletedServicesPurge, migrationWorkflow, notificationDelivery, + notificationRetention, oldBackupsCleanup, onDeploymentFailed, onRestoreFailed, @@ -54,5 +55,6 @@ export const { GET, POST, PUT } = serve({ serviceRestoreWorkflow, expiredDeletedServicesPurge, notificationDelivery, + notificationRetention, ], }); diff --git a/web/components/settings/email-settings.tsx b/web/components/settings/email-settings.tsx index 04dc4d8b..6da5c5f4 100644 --- a/web/components/settings/email-settings.tsx +++ b/web/components/settings/email-settings.tsx @@ -31,22 +31,23 @@ const ALERT_SETTINGS: AlertSetting[] = [ { field: "serverOfflineAlert", label: "Server Offline Alert", - description: "Receive an email when a server goes offline", + description: "Receive a notification when a server goes offline", }, { field: "buildFailure", label: "Build Failure Alert", - description: "Receive an email when a build fails", + description: "Receive a notification when a build fails", }, { field: "deploymentFailure", label: "Deployment Failure Alert", - description: "Receive an email when a deployment fails", + description: "Receive a notification when a deployment fails", }, { field: "deploymentMovedAlert", label: "Manual Recovery Alert", - description: "Receive an email when offline replicas need manual recovery", + description: + "Receive a notification when offline replicas need manual recovery", }, ]; @@ -134,8 +135,8 @@ export function EmailSettings({ initialAlertsConfig }: Props) {

- Configure which email notifications you want to receive. SMTP - settings are configured via environment variables. + Configure which notifications you want to receive. Email delivery + requires SMTP settings configured via environment variables.

diff --git a/web/lib/email/index.ts b/web/lib/email/index.ts index 1b21e4e4..1fe78ec0 100644 --- a/web/lib/email/index.ts +++ b/web/lib/email/index.ts @@ -4,7 +4,7 @@ import type { Transporter } from "nodemailer"; import nodemailer from "nodemailer"; import type { ReactElement } from "react"; import { db } from "@/db"; -import { getEmailAlertsConfig, getSmtpConfig } from "@/db/queries"; +import { getSmtpConfig } from "@/db/queries"; import { environments, memberInvitations, @@ -14,6 +14,7 @@ import { } from "@/db/schema"; import { formatDateTimeUtc } from "@/lib/date"; import type { NotificationEvent } from "@/lib/inngest/events/notification"; +import { notificationEventIsEnabled } from "@/lib/notifications"; import type { SmtpConfig } from "@/lib/settings-keys"; import { Alert } from "./templates/alert"; import { MemberInvitation } from "./templates/member-invitation"; @@ -360,16 +361,9 @@ export async function getNotificationEmailRecipients( if (event.kind === "member.invited") return [event.to]; - const alertsConfig = await getEmailAlertsConfig(); - const enabled = - (event.kind === "server.offline" && - alertsConfig?.serverOfflineAlert !== false) || - (event.kind === "manual_recovery.required" && - alertsConfig?.deploymentMovedAlert !== false) || - (event.kind === "build.failed" && alertsConfig?.buildFailure !== false) || - (event.kind === "deployment.failed" && - alertsConfig?.deploymentFailure !== false); - return enabled ? parseAlertEmails(config.alertEmails) : []; + return (await notificationEventIsEnabled(event)) + ? parseAlertEmails(config.alertEmails) + : []; } async function invitationIsDeliverable(event: NotificationEvent) { diff --git a/web/lib/inngest/functions/crons.ts b/web/lib/inngest/functions/crons.ts index 2b1052de..16079d9e 100644 --- a/web/lib/inngest/functions/crons.ts +++ b/web/lib/inngest/functions/crons.ts @@ -5,6 +5,7 @@ import { } from "@/lib/acme-manager"; import { cleanupOldBackups, runScheduledBackups } from "@/lib/backup-scheduler"; import { checkAndPersistControlPlaneUpdate } from "@/lib/control-plane-updates"; +import { cleanupReadNotifications } from "@/lib/notifications"; import { cleanupRegistryArtifactsDaily } from "@/lib/registry-retention"; import { checkAndRecoverStaleServers, @@ -183,3 +184,14 @@ export const registryArtifactRetention = inngest.createFunction( }); }, ); + +export const notificationRetention = inngest.createFunction( + { + id: "cron-notification-retention", + triggers: [cron("0 6 * * *")], + singleton: { mode: "skip" }, + }, + async ({ step }) => { + await step.run("cleanup-read-notifications", cleanupReadNotifications); + }, +); diff --git a/web/lib/inngest/functions/index.ts b/web/lib/inngest/functions/index.ts index fac237a5..04bbea4d 100644 --- a/web/lib/inngest/functions/index.ts +++ b/web/lib/inngest/functions/index.ts @@ -7,6 +7,7 @@ export { certificateRenewal, challengeCleanup, controlPlaneUpdateCheck, + notificationRetention, oldBackupsCleanup, registryArtifactRetention, scheduledBackupsCheck, diff --git a/web/lib/notifications/index.ts b/web/lib/notifications/index.ts index a6f80fbe..737c66e8 100644 --- a/web/lib/notifications/index.ts +++ b/web/lib/notifications/index.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; -import { eq, sql } from "drizzle-orm"; +import { and, eq, isNotNull, lt, sql } from "drizzle-orm"; import { db } from "@/db"; +import { getEmailAlertsConfig } from "@/db/queries"; import { environments, notifications, @@ -8,10 +9,13 @@ import { services, user, } from "@/db/schema"; +import { subtractUtcDays } from "@/lib/date"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; import type { NotificationEvent } from "@/lib/inngest/events/notification"; +const READ_NOTIFICATION_RETENTION_DAYS = 30; + export async function notify(event: NotificationEvent) { return inngest.send( inngestEvents.notificationRequested.create(event, { @@ -20,6 +24,22 @@ export async function notify(event: NotificationEvent) { ); } +export async function notificationEventIsEnabled(event: NotificationEvent) { + if (event.kind === "member.invited") return true; + + const config = await getEmailAlertsConfig(); + switch (event.kind) { + case "server.offline": + return config?.serverOfflineAlert !== false; + case "manual_recovery.required": + return config?.deploymentMovedAlert !== false; + case "build.failed": + return config?.buildFailure !== false; + case "deployment.failed": + return config?.deploymentFailure !== false; + } +} + async function serviceContext(serviceId: string) { return db .select({ @@ -71,6 +91,7 @@ export async function renderInAppNotification(event: NotificationEvent) { } export async function deliverInAppNotification(event: NotificationEvent) { + if (!(await notificationEventIsEnabled(event))) return; const rendered = await renderInAppNotification(event); if (!rendered) return; const recipients = await db @@ -93,3 +114,20 @@ export async function deliverInAppNotification(event: NotificationEvent) { target: [notifications.eventId, notifications.userId], }); } + +export async function cleanupReadNotifications(now = new Date()) { + const cutoff = subtractUtcDays(now, READ_NOTIFICATION_RETENTION_DAYS); + const result = await db + .delete(notifications) + .where( + and(isNotNull(notifications.readAt), lt(notifications.readAt, cutoff)), + ); + const deletedCount = result.rowCount ?? 0; + + if (deletedCount > 0) { + console.log( + `[notifications] deleted ${deletedCount} old read notifications`, + ); + } + return deletedCount; +} diff --git a/web/tests/inngest-route.test.ts b/web/tests/inngest-route.test.ts index 21326bb5..370fc961 100644 --- a/web/tests/inngest-route.test.ts +++ b/web/tests/inngest-route.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => { expiredDeletedServicesPurge: { id: "expired-deleted-services-purge" }, migrationWorkflow: { id: "migration-workflow" }, notificationDelivery: { id: "notification-delivery" }, + notificationRetention: { id: "notification-retention" }, oldBackupsCleanup: { id: "old-backups-cleanup" }, onDeploymentFailed: { id: "on-deployment-failed" }, onRestoreFailed: { id: "on-restore-failed" }, diff --git a/web/tests/notifications.test.ts b/web/tests/notifications.test.ts index 0ec0982e..e88c3d5e 100644 --- a/web/tests/notifications.test.ts +++ b/web/tests/notifications.test.ts @@ -1,15 +1,26 @@ -import { describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - send: vi.fn(), - create: vi.fn((data, options) => ({ - name: "notification/requested", - data, - ...options, - })), - deliverEmail: vi.fn(), - getEmailRecipients: vi.fn(), -})); +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const deleteWhere = vi.fn((_condition: SQL) => + Promise.resolve({ rowCount: 0 }), + ); + return { + send: vi.fn(), + create: vi.fn((data, options) => ({ + name: "notification/requested", + data, + ...options, + })), + deliverEmail: vi.fn(), + getEmailRecipients: vi.fn(), + getAlertsConfig: vi.fn(), + select: vi.fn(), + delete: vi.fn(() => ({ where: deleteWhere })), + deleteWhere, + }; +}); vi.mock("@/lib/inngest/client", () => ({ inngest: { @@ -22,16 +33,32 @@ vi.mock("@/lib/inngest/client", () => ({ vi.mock("@/lib/inngest/events", () => ({ inngestEvents: { notificationRequested: { create: mocks.create } }, })); -vi.mock("@/db", () => ({ db: {} })); +vi.mock("@/db", () => ({ + db: { select: mocks.select, delete: mocks.delete }, +})); +vi.mock("@/db/queries", () => ({ + getEmailAlertsConfig: mocks.getAlertsConfig, +})); vi.mock("@/lib/email", () => ({ deliverNotificationEmail: mocks.deliverEmail, getNotificationEmailRecipients: mocks.getEmailRecipients, })); import { notificationDelivery } from "@/lib/inngest/functions/notification-delivery"; -import { notify, renderInAppNotification } from "@/lib/notifications"; +import { + cleanupReadNotifications, + deliverInAppNotification, + notificationEventIsEnabled, + notify, + renderInAppNotification, +} from "@/lib/notifications"; describe("notification pipeline", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.deleteWhere.mockResolvedValue({ rowCount: 0 }); + }); + it("enqueues using the stable occurrence ID", async () => { mocks.send.mockResolvedValue({ ids: ["event-1"] }); const event = { @@ -72,6 +99,97 @@ describe("notification pipeline", () => { ).resolves.toBeNull(); }); + it("maps every operational event to its alert toggle", async () => { + mocks.getAlertsConfig.mockResolvedValue({ + serverOfflineAlert: false, + buildFailure: false, + deploymentFailure: false, + deploymentMovedAlert: false, + }); + + await expect( + notificationEventIsEnabled({ + kind: "server.offline", + occurrenceId: "offline-1", + serverId: "server-1", + serverName: "Edge", + }), + ).resolves.toBe(false); + await expect( + notificationEventIsEnabled({ + kind: "manual_recovery.required", + occurrenceId: "recovery-1", + serverId: "server-1", + serverName: "Edge", + impactedReplicas: 1, + serviceNames: ["API"], + }), + ).resolves.toBe(false); + await expect( + notificationEventIsEnabled({ + kind: "build.failed", + occurrenceId: "build-1", + serviceId: "service-1", + buildId: "build-1", + }), + ).resolves.toBe(false); + await expect( + notificationEventIsEnabled({ + kind: "deployment.failed", + occurrenceId: "deployment-1", + serviceId: "service-1", + serverId: "server-1", + }), + ).resolves.toBe(false); + }); + + it("defaults missing alert settings to enabled", async () => { + mocks.getAlertsConfig.mockResolvedValue(null); + + await expect( + notificationEventIsEnabled({ + kind: "build.failed", + occurrenceId: "build-1", + serviceId: "service-1", + buildId: "build-1", + }), + ).resolves.toBe(true); + }); + + it("skips in-app delivery when the event category is disabled", async () => { + mocks.getAlertsConfig.mockResolvedValue({ + serverOfflineAlert: false, + buildFailure: true, + deploymentFailure: true, + deploymentMovedAlert: true, + }); + + await deliverInAppNotification({ + kind: "server.offline", + occurrenceId: "offline-1", + serverId: "server-1", + serverName: "Edge", + }); + + expect(mocks.select).not.toHaveBeenCalled(); + }); + + it("deletes only notifications read more than 30 days ago", async () => { + mocks.deleteWhere.mockResolvedValue({ rowCount: 2 }); + const now = new Date("2026-08-03T12:00:00.000Z"); + + await expect(cleanupReadNotifications(now)).resolves.toBe(2); + + expect(mocks.delete).toHaveBeenCalledOnce(); + const condition = mocks.deleteWhere.mock.calls[0]?.[0] as SQL | undefined; + if (!condition) + throw new Error("notification cleanup condition is missing"); + const query = new PgDialect().sqlToQuery(condition); + expect(query.sql).toContain('"notifications"."read_at" is not null'); + expect(query.sql).toContain('"notifications"."read_at" < $1'); + expect(query.params).toEqual(["2026-07-04T12:00:00.000Z"]); + }); + it("runs channels as independent retryable steps", async () => { const event = { kind: "server.offline" as const,