Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions web/app/api/inngest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
expiredDeletedServicesPurge,
migrationWorkflow,
notificationDelivery,
notificationRetention,
oldBackupsCleanup,
onDeploymentFailed,
onRestoreFailed,
Expand Down Expand Up @@ -54,5 +55,6 @@ export const { GET, POST, PUT } = serve({
serviceRestoreWorkflow,
expiredDeletedServicesPurge,
notificationDelivery,
notificationRetention,
],
});
13 changes: 7 additions & 6 deletions web/components/settings/email-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
];

Expand Down Expand Up @@ -134,8 +135,8 @@ export function EmailSettings({ initialAlertsConfig }: Props) {
</Item>
<div className="p-4 space-y-4">
<p className="text-sm text-muted-foreground">
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.
</p>

<div className="space-y-4">
Expand Down
16 changes: 5 additions & 11 deletions web/lib/email/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions web/lib/inngest/functions/crons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
},
);
1 change: 1 addition & 0 deletions web/lib/inngest/functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export {
certificateRenewal,
challengeCleanup,
controlPlaneUpdateCheck,
notificationRetention,
oldBackupsCleanup,
registryArtifactRetention,
scheduledBackupsCheck,
Expand Down
40 changes: 39 additions & 1 deletion web/lib/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
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,
projects,
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, {
Expand All @@ -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({
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
1 change: 1 addition & 0 deletions web/tests/inngest-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
146 changes: 132 additions & 14 deletions web/tests/notifications.test.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
Loading