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,