diff --git a/.env.example b/.env.example index e026ed371..5429a7a34 100644 --- a/.env.example +++ b/.env.example @@ -60,7 +60,8 @@ GOOGLE_CLIENT_SECRET="" RESEND_API_KEY="" # Required when RESEND_API_KEY is set; use a domain verified in Resend. EMAIL_FROM="Databuddy " -ALERTS_EMAIL_FROM="Databuddy " +# Optional alert-specific sender; defaults to EMAIL_FROM. +ALERTS_EMAIL_FROM="" NEXT_PUBLIC_OPENAI_ADS_PIXEL_ID="" # Slack bot / AI agent adapter diff --git a/bun.lock b/bun.lock index 7f5fcdb36..0b837f563 100644 --- a/bun.lock +++ b/bun.lock @@ -530,6 +530,7 @@ "name": "@databuddy/notifications", "version": "0.0.1", "dependencies": { + "@databuddy/env": "workspace:*", "@databuddy/shared": "workspace:*", "resend": "^4.0.1", }, diff --git a/packages/notifications/package.json b/packages/notifications/package.json index c325b7c91..c3396e583 100644 --- a/packages/notifications/package.json +++ b/packages/notifications/package.json @@ -18,6 +18,7 @@ "test:integration": "bun test src/__tests__/integration" }, "dependencies": { + "@databuddy/env": "workspace:*", "@databuddy/shared": "workspace:*", "resend": "^4.0.1" }, diff --git a/packages/notifications/src/__tests__/alarm-config.test.ts b/packages/notifications/src/__tests__/alarm-config.test.ts index 421bc9b55..bab6e4155 100644 --- a/packages/notifications/src/__tests__/alarm-config.test.ts +++ b/packages/notifications/src/__tests__/alarm-config.test.ts @@ -1,10 +1,65 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import { config, createConfig } from "@databuddy/env/app"; import { buildAlarmNotificationConfig, buildAlarmNotificationTargets, } from "../alarm-config"; +import { NotificationClient } from "../client"; describe("buildAlarmNotificationTargets", () => { + test.each([ + ["", undefined, "App "], + [ + "Alerts ", + undefined, + "Alerts ", + ], + ["Alerts ", "alarm@example.com", "alarm@example.com"], + ["Alerts ", "", "Alerts "], + ] as const)( + "delivers alarms with sender %s and destination override %s", + async (alertsFrom, destinationFrom, expectedFrom) => { + const previousEmail = config.email; + const previousApiKey = process.env.RESEND_API_KEY; + const fetchMock = spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve(Response.json({ id: "email-example" })) + ); + process.env.RESEND_API_KEY = "re_test_key"; + try { + config.email = createConfig({ + ALERTS_EMAIL_FROM: alertsFrom, + EMAIL_FROM: "App ", + }).email; + const [target] = buildAlarmNotificationTargets([ + { + type: "email", + identifier: "recipient@example.com", + config: { from: destinationFrom }, + }, + ]); + expect(target?.channel).toBe("email"); + const result = await new NotificationClient(target?.clientConfig).send( + { title: "Site alert", message: "The site is unavailable." }, + { channels: ["email"] } + ); + expect(result).toEqual([{ channel: "email", success: true }]); + const request = fetchMock.mock.calls.at(-1)?.[1]; + expect(JSON.parse(String(request?.body))).toMatchObject({ + from: expectedFrom, + to: ["recipient@example.com"], + }); + } finally { + config.email = previousEmail; + fetchMock.mockRestore(); + if (previousApiKey === undefined) { + Reflect.deleteProperty(process.env, "RESEND_API_KEY"); + } else { + process.env.RESEND_API_KEY = previousApiKey; + } + } + } + ); + test("keeps same-channel destinations as separate delivery targets", () => { const firstSlack = "https://hooks.slack.com/services/T000/B000/first"; const secondSlack = "https://hooks.slack.com/services/T000/B000/second"; @@ -60,30 +115,6 @@ describe("buildAlarmNotificationTargets", () => { } } }); - - test("builds an email delivery target when Resend is configured", () => { - const previousApiKey = process.env.RESEND_API_KEY; - process.env.RESEND_API_KEY = "re_test_key"; - try { - const [target] = buildAlarmNotificationTargets([ - { - type: "email", - identifier: "recipient@example.com", - config: {}, - }, - ]); - expect(target?.channel).toBe("email"); - expect(target?.clientConfig.email?.defaultTo).toBe( - "recipient@example.com" - ); - } finally { - if (previousApiKey === undefined) { - delete process.env.RESEND_API_KEY; - } else { - process.env.RESEND_API_KEY = previousApiKey; - } - } - }); }); describe("buildAlarmNotificationConfig", () => { diff --git a/packages/notifications/src/alarm-config.ts b/packages/notifications/src/alarm-config.ts index 5e934f54a..68c032ee4 100644 --- a/packages/notifications/src/alarm-config.ts +++ b/packages/notifications/src/alarm-config.ts @@ -1,3 +1,4 @@ +import { config } from "@databuddy/env/app"; import type { NotificationClientConfig } from "./client"; import type { NotificationChannel } from "./types"; @@ -124,9 +125,7 @@ export function buildAlarmNotificationTargets( email: { defaultTo: dest.identifier, from: - typeof cfg.from === "string" - ? cfg.from - : "Databuddy ", + typeof cfg.from === "string" ? cfg.from : config.email.alertsFrom, sendEmailAction: async (payload: { to: string | string[]; subject: string; @@ -141,7 +140,7 @@ export function buildAlarmNotificationTargets( } const resend = new Resend(apiKey); const result = await resend.emails.send({ - from: payload.from || "Databuddy ", + from: payload.from || config.email.alertsFrom, to: Array.isArray(payload.to) ? payload.to : [payload.to], subject: payload.subject, html: payload.html || payload.text || "", diff --git a/turbo.json b/turbo.json index b009219f9..ccfa5f88c 100644 --- a/turbo.json +++ b/turbo.json @@ -8,6 +8,7 @@ "ui": "tui", "envMode": "strict", "globalEnv": [ + "ALERTS_EMAIL_FROM", "AUTUMN_SECRET_KEY", "BETTER_AUTH_SECRET", "BETTER_AUTH_URL", @@ -19,6 +20,7 @@ "DATABUDDY_ENCRYPTION_KEY", "DATABUDDY_WEBSITE_ID", "DB_POOL_MAX", + "EMAIL_FROM", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", "GOOGLE_CLIENT_ID",