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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <no-reply@example.com>"
ALERTS_EMAIL_FROM="Databuddy <alerts@example.com>"
# Optional alert-specific sender; defaults to EMAIL_FROM.
ALERTS_EMAIL_FROM=""
NEXT_PUBLIC_OPENAI_ADS_PIXEL_ID=""

# Slack bot / AI agent adapter
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/notifications/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"test:integration": "bun test src/__tests__/integration"
},
"dependencies": {
"@databuddy/env": "workspace:*",
"@databuddy/shared": "workspace:*",
"resend": "^4.0.1"
},
Expand Down
81 changes: 56 additions & 25 deletions packages/notifications/src/__tests__/alarm-config.test.ts
Original file line number Diff line number Diff line change
@@ -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 <app@example.com>"],
[
"Alerts <alerts@example.com>",
undefined,
"Alerts <alerts@example.com>",
],
["Alerts <alerts@example.com>", "alarm@example.com", "alarm@example.com"],
["Alerts <alerts@example.com>", "", "Alerts <alerts@example.com>"],
] 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 <app@example.com>",
}).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"] }
Comment thread
izadoesdev marked this conversation as resolved.
);
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";
Expand Down Expand Up @@ -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", () => {
Expand Down
7 changes: 3 additions & 4 deletions packages/notifications/src/alarm-config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { config } from "@databuddy/env/app";
import type { NotificationClientConfig } from "./client";
import type { NotificationChannel } from "./types";

Expand Down Expand Up @@ -124,9 +125,7 @@ export function buildAlarmNotificationTargets(
email: {
defaultTo: dest.identifier,
from:
typeof cfg.from === "string"
? cfg.from
: "Databuddy <alerts@databuddy.cc>",
typeof cfg.from === "string" ? cfg.from : config.email.alertsFrom,
sendEmailAction: async (payload: {
to: string | string[];
subject: string;
Expand All @@ -141,7 +140,7 @@ export function buildAlarmNotificationTargets(
}
const resend = new Resend(apiKey);
const result = await resend.emails.send({
from: payload.from || "Databuddy <alerts@databuddy.cc>",
from: payload.from || config.email.alertsFrom,
to: Array.isArray(payload.to) ? payload.to : [payload.to],
subject: payload.subject,
html: payload.html || payload.text || "",
Expand Down
2 changes: 2 additions & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"ui": "tui",
"envMode": "strict",
"globalEnv": [
"ALERTS_EMAIL_FROM",
"AUTUMN_SECRET_KEY",
"BETTER_AUTH_SECRET",
"BETTER_AUTH_URL",
Expand All @@ -19,6 +20,7 @@
"DATABUDDY_ENCRYPTION_KEY",
"DATABUDDY_WEBSITE_ID",
"DB_POOL_MAX",
"EMAIL_FROM",
"GITHUB_CLIENT_ID",
"GITHUB_CLIENT_SECRET",
"GOOGLE_CLIENT_ID",
Expand Down
Loading