From 8d604e60f458e1616d49947d3daf171ed6d70811 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Wed, 18 Feb 2026 11:39:25 -0500 Subject: [PATCH] Add email notifications via Resend for new incidents When the check runner opens a new incident, it fetches all contacts and sends alert emails via Resend with site name, error, and incident link. Email failures are caught and logged without blocking the monitoring loop. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 75 +++++++++++++++++++++++++- package.json | 3 +- src/__tests__/check-runner.test.ts | 84 +++++++++++++++++++++++++++-- src/__tests__/notifications.test.ts | 82 ++++++++++++++++++++++++++++ src/app/api/checks/run/route.ts | 29 ++++++++-- src/lib/notifications.ts | 49 +++++++++++++++++ 6 files changed, 310 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/notifications.test.ts create mode 100644 src/lib/notifications.ts diff --git a/package-lock.json b/package-lock.json index 8ca019e..b2a8348 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "@supabase/supabase-js": "^2.96.0", "next": "^15.2.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "resend": "^6.9.2" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -1827,6 +1828,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4430,6 +4437,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -6387,6 +6400,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -6561,6 +6580,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resend": { + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.2.tgz", + "integrity": "sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.3", + "svix": "1.84.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6992,6 +7032,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -7218,6 +7268,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", + "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, "node_modules/tailwindcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", @@ -7552,6 +7612,19 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index 5bc333b..7bf2add 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "@supabase/supabase-js": "^2.96.0", "next": "^15.2.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "resend": "^6.9.2" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/src/__tests__/check-runner.test.ts b/src/__tests__/check-runner.test.ts index 550146b..72df601 100644 --- a/src/__tests__/check-runner.test.ts +++ b/src/__tests__/check-runner.test.ts @@ -6,6 +6,12 @@ vi.mock('@/lib/checker', () => ({ checkSite: vi.fn(), })) +// Mock the notifications module +const mockSendIncidentAlert = vi.fn() +vi.mock('@/lib/notifications', () => ({ + sendIncidentAlert: (...args: unknown[]) => mockSendIncidentAlert(...args), +})) + // Mock the admin client const mockFrom = vi.fn() const mockSupabase = { from: mockFrom } @@ -20,6 +26,7 @@ import { checkSite } from '@/lib/checker' describe('POST /api/checks/run', () => { beforeEach(() => { vi.clearAllMocks() + mockSendIncidentAlert.mockResolvedValue(undefined) process.env.CRON_SECRET = 'test-secret' }) @@ -60,11 +67,9 @@ describe('POST /api/checks/run', () => { expect(body).toEqual({ message: 'No sites to check', checks: 0, incidents: 0 }) }) - test('records checks and opens incidents for failures', async () => { + test('records checks and opens incidents for failures, sends email', async () => { const site = { id: 'site-1', name: 'Test', url: 'https://example.com' } - // Mock sites query - const mockSelect = vi.fn() mockFrom.mockImplementation((table: string) => { if (table === 'sites') { return { select: () => ({ data: [site], error: null }) } @@ -91,10 +96,21 @@ describe('POST /api/checks/run', () => { }), }), }), - insert: vi.fn().mockReturnValue({ data: null, error: null }), + insert: () => ({ + select: () => ({ + single: () => ({ data: { id: 'inc-1' } }), + }), + }), } } - return { select: mockSelect } + if (table === 'contacts') { + return { + select: () => ({ + data: [{ email: 'alice@example.com' }], + }), + } + } + return {} }) vi.mocked(checkSite).mockResolvedValue({ @@ -112,5 +128,63 @@ describe('POST /api/checks/run', () => { const body = await response.json() expect(body.checks).toBe(1) expect(body.incidents).toBe(1) + expect(mockSendIncidentAlert).toHaveBeenCalledWith({ + siteName: 'Test', + siteUrl: 'https://example.com', + error: 'HTTP 503', + incidentId: 'inc-1', + contactEmails: ['alice@example.com'], + }) + }) + + test('does not send email for existing open incident', async () => { + const site = { id: 'site-1', name: 'Test', url: 'https://example.com' } + + mockFrom.mockImplementation((table: string) => { + if (table === 'sites') { + return { select: () => ({ data: [site], error: null }) } + } + if (table === 'checks') { + return { + insert: () => ({ + select: () => ({ + single: () => ({ + data: { id: 'check-2', site_id: 'site-1', status: 'failure' }, + }), + }), + }), + } + } + if (table === 'incidents') { + return { + select: () => ({ + eq: () => ({ + eq: () => ({ + limit: () => ({ + single: () => ({ data: { id: 'inc-existing' } }), + }), + }), + }), + }), + } + } + return {} + }) + + vi.mocked(checkSite).mockResolvedValue({ + status: 'failure', + statusCode: 503, + error: 'HTTP 503', + }) + + const request = new NextRequest('http://localhost/api/checks/run', { + method: 'POST', + headers: { authorization: 'Bearer test-secret' }, + }) + + const response = await POST(request) + const body = await response.json() + expect(body.incidents).toBe(0) + expect(mockSendIncidentAlert).not.toHaveBeenCalled() }) }) diff --git a/src/__tests__/notifications.test.ts b/src/__tests__/notifications.test.ts new file mode 100644 index 0000000..81e36f7 --- /dev/null +++ b/src/__tests__/notifications.test.ts @@ -0,0 +1,82 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" + +// Mock Resend +const mockSend = vi.fn() +vi.mock("resend", () => ({ + Resend: class { + emails = { send: mockSend } + }, +})) + +import { sendIncidentAlert } from "@/lib/notifications" + +describe("sendIncidentAlert", () => { + beforeEach(() => { + vi.clearAllMocks() + mockSend.mockResolvedValue({ id: "email-1" }) + }) + + test("sends email to all contacts with correct content", async () => { + await sendIncidentAlert({ + siteName: "Main Website", + siteUrl: "https://example.com", + error: "HTTP 503", + incidentId: "inc-1", + contactEmails: ["alice@example.com", "bob@example.com"], + }) + + expect(mockSend).toHaveBeenCalledTimes(1) + const call = mockSend.mock.calls[0][0] + expect(call.to).toEqual(["alice@example.com", "bob@example.com"]) + expect(call.subject).toContain("Main Website") + expect(call.subject).toContain("[Down]") + expect(call.html).toContain("Main Website") + expect(call.html).toContain("https://example.com") + expect(call.html).toContain("HTTP 503") + expect(call.html).toContain("/incidents/inc-1") + }) + + test("does nothing when contactEmails is empty", async () => { + await sendIncidentAlert({ + siteName: "Main Website", + siteUrl: "https://example.com", + error: "HTTP 503", + incidentId: "inc-1", + contactEmails: [], + }) + + expect(mockSend).not.toHaveBeenCalled() + }) + + test("handles email send failure gracefully", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + mockSend.mockRejectedValue(new Error("API rate limit")) + + await sendIncidentAlert({ + siteName: "Main Website", + siteUrl: "https://example.com", + error: "HTTP 503", + incidentId: "inc-1", + contactEmails: ["alice@example.com"], + }) + + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to send incident alert email:", + expect.any(Error) + ) + consoleSpy.mockRestore() + }) + + test("works without error field", async () => { + await sendIncidentAlert({ + siteName: "Main Website", + siteUrl: "https://example.com", + error: null, + incidentId: "inc-1", + contactEmails: ["alice@example.com"], + }) + + const call = mockSend.mock.calls[0][0] + expect(call.html).not.toContain("Error:") + }) +}) diff --git a/src/app/api/checks/run/route.ts b/src/app/api/checks/run/route.ts index 0fe636d..0e18aad 100644 --- a/src/app/api/checks/run/route.ts +++ b/src/app/api/checks/run/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { createAdminClient } from '@/lib/supabase/admin' import { checkSite } from '@/lib/checker' +import { sendIncidentAlert } from '@/lib/notifications' export const maxDuration = 60 @@ -59,12 +60,30 @@ export async function POST(request: NextRequest) { .single() if (!openIncident) { - await supabase.from('incidents').insert({ - site_id: site.id, - check_id: check.id, - status: 'open', - }) + const { data: incident } = await supabase + .from('incidents') + .insert({ + site_id: site.id, + check_id: check.id, + status: 'open', + }) + .select('id') + .single() newIncidents++ + + if (incident) { + const { data: contacts } = await supabase + .from('contacts') + .select('email') + + await sendIncidentAlert({ + siteName: site.name, + siteUrl: site.url, + error, + incidentId: incident.id, + contactEmails: (contacts ?? []).map((c: { email: string }) => c.email), + }) + } } } } diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts new file mode 100644 index 0000000..f528caf --- /dev/null +++ b/src/lib/notifications.ts @@ -0,0 +1,49 @@ +import { Resend } from "resend" + +let _resend: Resend | null = null +function getResend() { + if (!_resend) _resend = new Resend(process.env.RESEND_API_KEY) + return _resend +} + +type IncidentAlertParams = { + siteName: string + siteUrl: string + error: string | null + incidentId: string + contactEmails: string[] +} + +export async function sendIncidentAlert({ + siteName, + siteUrl, + error, + incidentId, + contactEmails, +}: IncidentAlertParams) { + if (contactEmails.length === 0) return + + const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://status.rootsofprogress.org" + const incidentLink = `${appUrl}/incidents/${incidentId}` + + const subject = `[Down] ${siteName} is not responding` + const html = ` +

${siteName} is down

+

URL: ${siteUrl}

+ ${error ? `

Error: ${error}

` : ""} +

View incident details

+ `.trim() + + const fromAddress = process.env.RESEND_FROM_EMAIL || "alerts@status.rootsofprogress.org" + + try { + await getResend().emails.send({ + from: fromAddress, + to: contactEmails, + subject, + html, + }) + } catch (err) { + console.error("Failed to send incident alert email:", err) + } +}