From b265b2dcc3905fc83638519b8790737d87a4415c Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:31:55 +0300 Subject: [PATCH 1/4] fix(api): honor self-hosted email sender settings --- .env.example | 3 +- README.md | 15 +++++ bun.lock | 1 + docker-compose.selfhost.yml | 4 ++ packages/notifications/package.json | 1 + .../src/__tests__/alarm-config.test.ts | 59 ++++++++++++++++++- packages/notifications/src/alarm-config.ts | 7 +-- turbo.json | 2 + 8 files changed, 86 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index e026ed371f..5429a7a34d 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/README.md b/README.md index 64cc0c3fc6..161cb05e84 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,21 @@ Services started: All ports are configurable via env vars (`API_PORT`, `BASKET_PORT`, etc.). See the compose file comments for the full env var reference. +### Email delivery + +Set `RESEND_API_KEY` and `EMAIL_FROM` in `.env` to send authentication emails and alerts. The sender must use a domain verified in your Resend account: + +```dotenv +RESEND_API_KEY="your-resend-api-key" +EMAIL_FROM="Databuddy " +``` + +Replace `example.com` with your verified domain. Leave `ALERTS_EMAIL_FROM` empty to use `EMAIL_FROM` for alerts, or set it to another verified sender. An alarm's own sender setting takes precedence. Without a sender override, Databuddy uses its hosted `databuddy.cc` addresses, which your Resend account cannot send from. Recreate the services after changing `.env`: + +```bash +docker compose -f docker-compose.selfhost.yml up -d +``` + ## 🤝 Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. diff --git a/bun.lock b/bun.lock index 7f5fcdb369..0b837f563f 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/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 324f667eaf..5bee9f6c20 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -135,6 +135,10 @@ services: CLICKHOUSE_URL: "http://${CLICKHOUSE_USER:-default}:${CLICKHOUSE_PASSWORD:?Set CLICKHOUSE_PASSWORD in your environment}@clickhouse:8123/${CLICKHOUSE_DB:-databuddy_analytics}" DATABUDDY_ENCRYPTION_KEY: ${DATABUDDY_ENCRYPTION_KEY:?Set DATABUDDY_ENCRYPTION_KEY in your environment} IP_HASH_SALT: ${IP_HASH_SALT:?Set IP_HASH_SALT in your environment} + DASHBOARD_URL: ${DASHBOARD_URL:?Set DASHBOARD_URL in your environment} + EMAIL_FROM: ${EMAIL_FROM:-} + ALERTS_EMAIL_FROM: ${ALERTS_EMAIL_FROM:-} + RESEND_API_KEY: ${RESEND_API_KEY:-} SUPERLOG_API_KEY: ${SUPERLOG_API_KEY:-} SELFHOST: "true" healthcheck: diff --git a/packages/notifications/package.json b/packages/notifications/package.json index c325b7c91b..c3396e5837 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 421bc9b55d..317e3188b9 100644 --- a/packages/notifications/src/__tests__/alarm-config.test.ts +++ b/packages/notifications/src/__tests__/alarm-config.test.ts @@ -1,10 +1,67 @@ -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("delivers alarms with configured senders and preserves destination overrides", async () => { + 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 { + for (const [alertsFrom, destinationFrom, expectedFrom] of [ + ["", undefined, "App "], + [ + "Alerts ", + undefined, + "Alerts ", + ], + [ + "Alerts ", + "alarm@example.com", + "alarm@example.com", + ], + ["Alerts ", "", "Alerts "], + ] as const) { + config.email = createConfig({ + ALERTS_EMAIL_FROM: alertsFrom, + EMAIL_FROM: "App ", + }).email; + const [target] = buildAlarmNotificationTargets([ + { + type: "email", + identifier: "recipient@example.com", + config: { from: destinationFrom }, + }, + ]); + 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) { + delete 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"; diff --git a/packages/notifications/src/alarm-config.ts b/packages/notifications/src/alarm-config.ts index 5e934f54ab..68c032ee4f 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 b009219f92..ccfa5f88cd 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", From 7aae8d65a509700060bb354b8ef7afac47479c53 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:34:11 +0300 Subject: [PATCH 2/4] refactor(api): keep self-host setup in its own change --- README.md | 15 --------------- docker-compose.selfhost.yml | 4 ---- 2 files changed, 19 deletions(-) diff --git a/README.md b/README.md index 161cb05e84..64cc0c3fc6 100644 --- a/README.md +++ b/README.md @@ -100,21 +100,6 @@ Services started: All ports are configurable via env vars (`API_PORT`, `BASKET_PORT`, etc.). See the compose file comments for the full env var reference. -### Email delivery - -Set `RESEND_API_KEY` and `EMAIL_FROM` in `.env` to send authentication emails and alerts. The sender must use a domain verified in your Resend account: - -```dotenv -RESEND_API_KEY="your-resend-api-key" -EMAIL_FROM="Databuddy " -``` - -Replace `example.com` with your verified domain. Leave `ALERTS_EMAIL_FROM` empty to use `EMAIL_FROM` for alerts, or set it to another verified sender. An alarm's own sender setting takes precedence. Without a sender override, Databuddy uses its hosted `databuddy.cc` addresses, which your Resend account cannot send from. Recreate the services after changing `.env`: - -```bash -docker compose -f docker-compose.selfhost.yml up -d -``` - ## 🤝 Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 5bee9f6c20..324f667eaf 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -135,10 +135,6 @@ services: CLICKHOUSE_URL: "http://${CLICKHOUSE_USER:-default}:${CLICKHOUSE_PASSWORD:?Set CLICKHOUSE_PASSWORD in your environment}@clickhouse:8123/${CLICKHOUSE_DB:-databuddy_analytics}" DATABUDDY_ENCRYPTION_KEY: ${DATABUDDY_ENCRYPTION_KEY:?Set DATABUDDY_ENCRYPTION_KEY in your environment} IP_HASH_SALT: ${IP_HASH_SALT:?Set IP_HASH_SALT in your environment} - DASHBOARD_URL: ${DASHBOARD_URL:?Set DASHBOARD_URL in your environment} - EMAIL_FROM: ${EMAIL_FROM:-} - ALERTS_EMAIL_FROM: ${ALERTS_EMAIL_FROM:-} - RESEND_API_KEY: ${RESEND_API_KEY:-} SUPERLOG_API_KEY: ${SUPERLOG_API_KEY:-} SELFHOST: "true" healthcheck: From 47a43a6231137153d0d62f3561f2ec93d6d2cce7 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:07:51 +0300 Subject: [PATCH 3/4] test(api): reuse outbound alarm coverage --- .../src/__tests__/alarm-config.test.ts | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/packages/notifications/src/__tests__/alarm-config.test.ts b/packages/notifications/src/__tests__/alarm-config.test.ts index 317e3188b9..685252d67a 100644 --- a/packages/notifications/src/__tests__/alarm-config.test.ts +++ b/packages/notifications/src/__tests__/alarm-config.test.ts @@ -40,6 +40,7 @@ describe("buildAlarmNotificationTargets", () => { 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"] } @@ -117,30 +118,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", () => { From f27025c4a7c35b4dc5ed47e546c57a6032109f50 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:01:09 +0300 Subject: [PATCH 4/4] test(api): isolate alarm sender regression cases --- .../src/__tests__/alarm-config.test.ts | 59 +++++++++---------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/packages/notifications/src/__tests__/alarm-config.test.ts b/packages/notifications/src/__tests__/alarm-config.test.ts index 685252d67a..bab6e41553 100644 --- a/packages/notifications/src/__tests__/alarm-config.test.ts +++ b/packages/notifications/src/__tests__/alarm-config.test.ts @@ -7,28 +7,25 @@ import { import { NotificationClient } from "../client"; describe("buildAlarmNotificationTargets", () => { - test("delivers alarms with configured senders and preserves destination overrides", async () => { - 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 { - for (const [alertsFrom, destinationFrom, expectedFrom] of [ - ["", undefined, "App "], - [ - "Alerts ", - undefined, - "Alerts ", - ], - [ - "Alerts ", - "alarm@example.com", - "alarm@example.com", - ], - ["Alerts ", "", "Alerts "], - ] as const) { + 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 ", @@ -51,17 +48,17 @@ describe("buildAlarmNotificationTargets", () => { from: expectedFrom, to: ["recipient@example.com"], }); - } - } finally { - config.email = previousEmail; - fetchMock.mockRestore(); - if (previousApiKey === undefined) { - delete process.env.RESEND_API_KEY; - } else { - process.env.RESEND_API_KEY = previousApiKey; + } 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";