feat: add ntfy notification support

This commit is contained in:
joaquimpeixoto committed 2026-05-27 16:57:11 +01:00
1 parent 4a939ad2c4
commit 8a83bc99e9
17 files changed
+492 -27

No files matched your search

+8
View File
@@ -78,6 +78,14 @@ function buildDefaults(data: Notification | null): NotificationFormData {
accessToken: data.accessToken || "",
};
}
if (data?.type === "ntfy") {
return {
type: "ntfy",
notificationName: data.notificationName || "",
address: data.address || "",
topic: data.topic || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return {
type: "email",
@@ -150,7 +150,8 @@ const NotificationsCreatePage = () => {
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "twilio" && (
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
@@ -174,6 +175,48 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "ntfy" && (
<ConfigBox
title={t("pages.notifications.form.ntfy.title")}
subtitle={t("pages.notifications.form.ntfy.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.ntfy.optionServerUrl")}
placeholder={t("pages.notifications.form.ntfy.placeholderServerUrl")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="topic"
control={control}
defaultValue={"topic" in defaults ? defaults.topic : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.ntfy.optionTopic")}
placeholder={t("pages.notifications.form.ntfy.placeholderTopic")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
+2
View File
@@ -9,6 +9,7 @@ export const NotificationChannels = [
"telegram",
"pushover",
"twilio",
"ntfy",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
@@ -25,6 +26,7 @@ export interface Notification {
accessToken?: string;
accountSid?: string;
twilioPhoneNumber?: string;
topic?: string;
createdAt: string;
updatedAt: string;
}
+7
View File
@@ -70,6 +70,12 @@ const twilioSchema = baseSchema.extend({
twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"),
});
const ntfySchema = baseSchema.extend({
type: z.literal("ntfy"),
address: z.string().min(1, "Server URL is required").url("Please enter a valid URL"),
topic: z.string().min(1, "Topic is required"),
});
export const notificationSchema = z.discriminatedUnion("type", [
emailSchema,
slackSchema,
@@ -81,6 +87,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
telegramSchema,
pushoverSchema,
twilioSchema,
ntfySchema,
]);
export type NotificationFormData = z.infer<typeof notificationSchema>;
+8
View File
@@ -1155,6 +1155,14 @@
"placeholderFromNumber": "+15551234567",
"optionToNumber": "To number (recipient)",
"placeholderToNumber": "+15559876543"
},
"ntfy": {
"title": "ntfy configuration",
"description": "Configure an ntfy server and topic for push notifications.",
"optionServerUrl": "Server URL",
"placeholderServerUrl": "https://ntfy.sh",
"optionTopic": "Topic",
"placeholderTopic": "checkmate-alerts"
}
},
"table": {
+107 -2
View File
@@ -392,6 +392,12 @@
"type": "string"
}
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"secret": {
"type": "string"
},
@@ -469,6 +475,7 @@
"ignoreTlsErrors",
"useAdvancedMatching",
"notifications",
"tags",
"cpuAlertThreshold",
"memoryAlertThreshold",
"diskAlertThreshold",
@@ -545,6 +552,9 @@
},
{
"$ref": "#/components/schemas/TwilioNotification"
},
{
"$ref": "#/components/schemas/NtfyNotification"
}
],
"discriminator": {
@@ -559,7 +569,8 @@
"teams": "#/components/schemas/TeamsNotification",
"telegram": "#/components/schemas/TelegramNotification",
"pushover": "#/components/schemas/PushoverNotification",
"twilio": "#/components/schemas/TwilioNotification"
"twilio": "#/components/schemas/TwilioNotification",
"ntfy": "#/components/schemas/NtfyNotification"
}
}
},
@@ -1003,6 +1014,34 @@
"phone": "+15551234567",
"twilioPhoneNumber": "+15557654321"
}
},
"NtfyNotification": {
"type": "object",
"properties": {
"notificationName": {
"type": "string",
"minLength": 1
},
"type": {
"type": "string",
"enum": ["ntfy"]
},
"address": {
"type": "string",
"format": "uri"
},
"topic": {
"type": "string",
"minLength": 1
}
},
"required": ["notificationName", "type", "address", "topic"],
"example": {
"notificationName": "ntfy topic",
"type": "ntfy",
"address": "https://ntfy.sh",
"topic": "checkmate-alerts"
}
}
},
"parameters": {}
@@ -2362,6 +2401,16 @@
"profilePicture": {
"type": "string"
},
"password": {
"type": "string",
"minLength": 8,
"pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$"
},
"newPassword": {
"type": "string",
"minLength": 8,
"pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$"
},
"profileImage": {
"type": "string",
"format": "binary"
@@ -5207,7 +5256,8 @@
"type": "array",
"items": {
"type": "string"
}
},
"minItems": 1
},
"duration": {
"type": "number"
@@ -5478,6 +5528,24 @@
"required": false,
"name": "filter",
"in": "query"
},
{
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"required": false,
"name": "tags",
"in": "query"
}
],
"responses": {
@@ -5672,6 +5740,24 @@
"name": "type",
"in": "query"
},
{
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"required": false,
"name": "tags",
"in": "query"
},
{
"schema": {
"type": "boolean"
@@ -6924,6 +7010,12 @@
"type": "string"
}
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"secret": {
"type": "string"
},
@@ -7586,6 +7678,13 @@
},
"default": []
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"secret": {
"type": "string"
},
@@ -8104,6 +8203,12 @@
"type": "string"
}
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"secret": {
"type": "string"
},
+4
View File
@@ -68,6 +68,10 @@ const notificationVariantMeta: Record<string, { component: string; example: Reco
twilioPhoneNumber: "+15557654321",
},
},
ntfy: {
component: "NtfyNotification",
example: { notificationName: "ntfy topic", type: "ntfy", address: "https://ntfy.sh", topic: "checkmate-alerts" },
},
};
const decoratedVariants = createNotificationBodyValidation.options.map((variant) => {
+3
View File
@@ -33,6 +33,7 @@ import {
TelegramProvider,
PushoverProvider,
TwilioProvider,
NtfyProvider,
// Interfaces
INetworkService,
IEmailService,
@@ -304,6 +305,7 @@ export const initializeServices = async ({
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
const twilioProvider = new TwilioProvider(logger);
const ntfyProvider = new NtfyProvider(logger);
const notificationsService = new NotificationsService(
notificationsRepository,
@@ -318,6 +320,7 @@ export const initializeServices = async ({
telegramProvider,
pushoverProvider,
twilioProvider,
ntfyProvider,
settingsService,
logger,
notificationMessageBuilder
+14 -1
View File
@@ -25,7 +25,19 @@ const NotificationSchema = new Schema<NotificationDocument>(
},
type: {
type: String,
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover", "twilio"] as NotificationChannel[],
enum: [
"email",
"slack",
"discord",
"webhook",
"pager_duty",
"matrix",
"teams",
"telegram",
"pushover",
"twilio",
"ntfy",
] as NotificationChannel[],
required: true,
},
notificationName: {
@@ -39,6 +51,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
accessToken: { type: String },
accountSid: { type: String },
twilioPhoneNumber: { type: String },
topic: { type: String },
},
{
timestamps: true,
@@ -34,6 +34,7 @@ class MongoNotificationsRepository implements INotificationsRepository {
accessToken: doc.accessToken ?? undefined,
accountSid: doc.accountSid ?? undefined,
twilioPhoneNumber: doc.twilioPhoneNumber ?? undefined,
topic: doc.topic ?? undefined,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
+1
View File
@@ -33,6 +33,7 @@ export * from "@/service/infrastructure/notificationProviders/webhook.js";
export * from "@/service/infrastructure/notificationProviders/telegram.js";
export * from "@/service/infrastructure/notificationProviders/pushover.js";
export * from "@/service/infrastructure/notificationProviders/twilio.js";
export * from "@/service/infrastructure/notificationProviders/ntfy.js";
// System services
export * from "@/service/system/settingsService.js";
@@ -0,0 +1,128 @@
const SERVICE_NAME = "NtfyProvider";
import type { Notification } from "@/types/index.js";
import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got from "got";
export class NtfyProvider extends NotificationProvider {
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.address || !notification.topic) {
return false;
}
try {
await got.post(this.buildTopicUrl(notification.address, notification.topic), {
body: getTestMessage(),
headers: {
Title: "Checkmate test notification",
Priority: "default",
},
...this.gotRequestOptions(),
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "ntfy test alert failed",
service: SERVICE_NAME,
method: "sendTestAlert",
stack: err?.stack,
});
return false;
}
}
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address || !notification.topic) {
return false;
}
try {
await got.post(this.buildTopicUrl(notification.address, notification.topic), {
body: this.buildNtfyText(message),
headers: {
Title: message.content.title,
Priority: this.mapPriority(message.severity),
Tags: this.mapTags(message.severity),
},
...this.gotRequestOptions(),
});
this.logger.info({
message: "ntfy notification sent",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "ntfy alert failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: err?.stack,
});
return false;
}
}
private buildTopicUrl(address: string, topic: string): string {
return `${address.replace(/\/+$/, "")}/${encodeURIComponent(topic)}`;
}
private buildNtfyText(message: NotificationMessage): string {
const lines = [
message.content.summary,
"",
"Monitor Details:",
`- Name: ${message.monitor.name}`,
`- URL: ${message.monitor.url}`,
`- Type: ${message.monitor.type}`,
`- Status: ${message.monitor.status}`,
];
if (message.content.details && message.content.details.length > 0) {
lines.push("", "Additional Information:");
message.content.details.forEach((detail) => lines.push(`- ${detail}`));
}
if (message.content.thresholds && message.content.thresholds.length > 0) {
lines.push("", "Threshold Breaches:");
message.content.thresholds.forEach((breach) => {
lines.push(`- ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
});
}
if (message.content.incident) {
lines.push("", `${message.clientHost}/infrastructure/${message.monitor.id}`);
}
return lines.join("\n");
}
private mapPriority(severity: NotificationMessage["severity"]): string {
switch (severity) {
case "critical":
return "high";
case "warning":
return "default";
case "success":
return "low";
default:
return "default";
}
}
private mapTags(severity: NotificationMessage["severity"]): string {
switch (severity) {
case "critical":
return "rotating_light";
case "warning":
return "warning";
case "success":
return "white_check_mark";
default:
return "information_source";
}
}
}
@@ -36,6 +36,7 @@ export class NotificationsService implements INotificationsService {
private telegramProvider: INotificationProvider;
private pushoverProvider: INotificationProvider;
private twilioProvider: INotificationProvider;
private ntfyProvider: INotificationProvider;
private logger: ILogger;
private settingsService: ISettingsService;
private notificationMessageBuilder: INotificationMessageBuilder;
@@ -53,6 +54,7 @@ export class NotificationsService implements INotificationsService {
telegramProvider: INotificationProvider,
pushoverProvider: INotificationProvider,
twilioProvider: INotificationProvider,
ntfyProvider: INotificationProvider,
settingsService: ISettingsService,
logger: ILogger,
notificationMessageBuilder: INotificationMessageBuilder
@@ -69,6 +71,7 @@ export class NotificationsService implements INotificationsService {
this.telegramProvider = telegramProvider;
this.pushoverProvider = pushoverProvider;
this.twilioProvider = twilioProvider;
this.ntfyProvider = ntfyProvider;
this.settingsService = settingsService;
this.logger = logger;
this.notificationMessageBuilder = notificationMessageBuilder;
@@ -112,6 +115,8 @@ export class NotificationsService implements INotificationsService {
return await this.pushoverProvider.sendMessage!(notification, notificationMessage);
case "twilio":
return await this.twilioProvider.sendMessage!(notification, notificationMessage);
case "ntfy":
return await this.ntfyProvider.sendMessage!(notification, notificationMessage);
default:
this.logger.warn({
message: `Unknown notification type: ${notification.type}`,
@@ -178,6 +183,8 @@ export class NotificationsService implements INotificationsService {
return await this.pushoverProvider.sendTestAlert(notification);
case "twilio":
return await this.twilioProvider.sendTestAlert(notification);
case "ntfy":
return await this.ntfyProvider.sendTestAlert(notification);
default:
return false;
}
+2
View File
@@ -9,6 +9,7 @@ export const NotificationChannels = [
"telegram",
"pushover",
"twilio",
"ntfy",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
@@ -25,6 +26,7 @@ export interface Notification {
accessToken?: string;
accountSid?: string;
twilioPhoneNumber?: string;
topic?: string;
createdAt: string;
updatedAt: string;
}
@@ -88,6 +88,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
phone: z.string().min(1, "Recipient phone number is required"),
twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"),
}),
// ntfy notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
type: z.literal("ntfy"),
address: z.url({ message: "Please enter a valid ntfy server URL" }),
topic: z.string().min(1, "Topic is required"),
}),
]);
export const testNotificationBodyValidation = createNotificationBodyValidation;
@@ -0,0 +1,112 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
const { NtfyProvider } = await import("../../../../src/service/infrastructure/notificationProviders/ntfy.ts");
const createProvider = () => {
const logger = createMockLogger();
return { provider: new NtfyProvider(logger as any), logger };
};
const makeNtfyNotification = (overrides = {}) =>
makeNotification({
address: "https://ntfy.sh/",
topic: "checkmate-alerts",
...overrides,
});
testNotificationProviderContract("NtfyProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNtfyNotification(),
});
describe("NtfyProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("posts the test message to the configured topic", async () => {
expect(await createProvider().provider.sendTestAlert(makeNtfyNotification())).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
"https://ntfy.sh/checkmate-alerts",
expect.objectContaining({
body: expect.any(String),
headers: expect.objectContaining({ Title: "Checkmate test notification" }),
})
);
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNtfyNotification({ address: "" }))).toBe(false);
});
it("returns false when topic is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNtfyNotification({ topic: "" }))).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNtfyNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
});
describe("sendMessage", () => {
it("posts plain text with severity headers to the configured topic", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNtfyNotification() as any, makeMessage())).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
"https://ntfy.sh/checkmate-alerts",
expect.objectContaining({
body: expect.stringContaining("Monitor Details"),
headers: expect.objectContaining({
Title: "Monitor Down: Test Monitor",
Priority: "high",
Tags: "rotating_light",
}),
})
);
});
it("url-encodes topics", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNtfyNotification({ topic: "ops alerts" }) as any, makeMessage());
expect(mockGotPost.mock.calls[0][0]).toBe("https://ntfy.sh/ops%20alerts");
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNtfyNotification({ address: "" }) as any, makeMessage())).toBe(false);
});
it("returns false when topic is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNtfyNotification({ topic: "" }) as any, makeMessage())).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNtfyNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalled();
});
it("includes threshold breaches in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNtfyNotification() as any, makeMessageWithThresholds());
expect(mockGotPost.mock.calls[0][1].body).toContain("CPU");
});
it("includes incident links in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNtfyNotification() as any, makeMessageWithIncident());
expect(mockGotPost.mock.calls[0][1].body).toContain("/infrastructure/mon-1");
});
});
});
@@ -47,6 +47,7 @@ const createService = (overrides?: Record<string, unknown>) => {
const telegramProvider = createProvider();
const pushoverProvider = createProvider();
const twilioProvider = createProvider();
const ntfyProvider = createProvider();
const settingsService = createSettingsService();
const notificationMessageBuilder = createMessageBuilder();
@@ -64,6 +65,7 @@ const createService = (overrides?: Record<string, unknown>) => {
telegramProvider,
pushoverProvider,
twilioProvider,
ntfyProvider,
settingsService,
notificationMessageBuilder,
...overrides,
@@ -82,6 +84,7 @@ const createService = (overrides?: Record<string, unknown>) => {
defaults.telegramProvider as any,
defaults.pushoverProvider as any,
defaults.twilioProvider as any,
defaults.ntfyProvider as any,
defaults.settingsService as any,
defaults.logger as any,
defaults.notificationMessageBuilder as any
@@ -147,7 +150,7 @@ describe("NotificationsService", () => {
});
it("routes to correct provider for each notification type", async () => {
const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram", "pushover", "twilio"] as const;
const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram", "pushover", "twilio", "ntfy"] as const;
for (const type of types) {
const deps = createService();
(deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]);
@@ -165,6 +168,7 @@ describe("NotificationsService", () => {
telegram: deps.telegramProvider,
pushover: deps.pushoverProvider,
twilio: deps.twilioProvider,
ntfy: deps.ntfyProvider,
};
expect(providerMap[type].sendMessage).toHaveBeenCalledTimes(1);
}
@@ -238,30 +242,40 @@ describe("NotificationsService", () => {
// ── sendTestNotification ─────────────────────────────────────────────────
describe("sendTestNotification", () => {
it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"], ["pushover"], ["twilio"]] as const)(
"routes %s to the correct provider",
async (type) => {
const deps = createService();
const notification = makeNotification({ type: type as any });
it.each([
["email"],
["slack"],
["discord"],
["pager_duty"],
["matrix"],
["webhook"],
["teams"],
["telegram"],
["pushover"],
["twilio"],
["ntfy"],
] as const)("routes %s to the correct provider", async (type) => {
const deps = createService();
const notification = makeNotification({ type: type as any });
const result = await deps.service.sendTestNotification(notification);
const result = await deps.service.sendTestNotification(notification);
expect(result).toBe(true);
const providerMap: Record<string, ReturnType<typeof createProvider>> = {
webhook: deps.webhookProvider,
slack: deps.slackProvider,
matrix: deps.matrixProvider,
pager_duty: deps.pagerDutyProvider,
discord: deps.discordProvider,
email: deps.emailProvider,
teams: deps.teamsProvider,
telegram: deps.telegramProvider,
pushover: deps.pushoverProvider,
twilio: deps.twilioProvider,
};
expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification);
}
);
expect(result).toBe(true);
const providerMap: Record<string, ReturnType<typeof createProvider>> = {
webhook: deps.webhookProvider,
slack: deps.slackProvider,
matrix: deps.matrixProvider,
pager_duty: deps.pagerDutyProvider,
discord: deps.discordProvider,
email: deps.emailProvider,
teams: deps.teamsProvider,
telegram: deps.telegramProvider,
pushover: deps.pushoverProvider,
twilio: deps.twilioProvider,
ntfy: deps.ntfyProvider,
};
expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification);
});
it("returns false for unknown notification type", async () => {
const { service } = createService();