diff --git a/src/components/config/NotificationSettings.tsx b/src/components/config/NotificationSettings.tsx
index d8263d0..7754322 100644
--- a/src/components/config/NotificationSettings.tsx
+++ b/src/components/config/NotificationSettings.tsx
@@ -93,7 +93,7 @@ export function NotificationSettings({
@@ -307,6 +308,92 @@ export function NotificationSettings({
)}
+ {/* Gotify configuration */}
+ {notificationConfig.provider === "gotify" && (
+
+
Gotify Settings
+
+
+
+
+ onNotificationChange({
+ ...notificationConfig,
+ gotify: {
+ ...notificationConfig.gotify!,
+ url: e.target.value,
+ token: notificationConfig.gotify?.token || "",
+ priority: notificationConfig.gotify?.priority ?? 5,
+ },
+ })
+ }
+ />
+
+ URL of your Gotify server
+
+
+
+
+
+
+ onNotificationChange({
+ ...notificationConfig,
+ gotify: {
+ ...notificationConfig.gotify!,
+ url: notificationConfig.gotify?.url || "",
+ token: e.target.value,
+ priority: notificationConfig.gotify?.priority ?? 5,
+ },
+ })
+ }
+ />
+
+ Create an application in Gotify and paste its token here
+
+
+
+
+
+
+ onNotificationChange({
+ ...notificationConfig,
+ gotify: {
+ ...notificationConfig.gotify!,
+ url: notificationConfig.gotify?.url || "",
+ token: notificationConfig.gotify?.token || "",
+ priority: Math.min(10, Math.max(0, Number(e.target.value) || 0)),
+ },
+ })
+ }
+ />
+
+ Error notifications always use priority 8 regardless of this setting
+
+
+
+ )}
+
{/* Event toggles */}
Notification Events
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index c965803..1f21dcf 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -138,14 +138,21 @@ export const appriseConfigSchema = z.object({
tag: z.string().optional(),
});
+export const gotifyConfigSchema = z.object({
+ url: z.string().default(""),
+ token: z.string().default(""),
+ priority: z.number().int().min(0).max(10).default(5),
+});
+
export const notificationConfigSchema = z.object({
enabled: z.boolean().default(false),
- provider: z.enum(["ntfy", "apprise"]).default("ntfy"),
+ provider: z.enum(["ntfy", "apprise", "gotify"]).default("ntfy"),
notifyOnSyncError: z.boolean().default(true),
notifyOnSyncSuccess: z.boolean().default(false),
notifyOnNewRepo: z.boolean().default(false),
ntfy: ntfyConfigSchema.optional(),
apprise: appriseConfigSchema.optional(),
+ gotify: gotifyConfigSchema.optional(),
});
export type NotificationConfig = z.infer;
diff --git a/src/lib/notification-service.test.ts b/src/lib/notification-service.test.ts
index b068d7e..c2d2849 100644
--- a/src/lib/notification-service.test.ts
+++ b/src/lib/notification-service.test.ts
@@ -71,6 +71,32 @@ describe("sendNotification", () => {
expect(url).toBe("http://apprise:8000/notify/my-token");
});
+ test("sends gotify notification when provider is gotify", async () => {
+ const config: NotificationConfig = {
+ enabled: true,
+ provider: "gotify",
+ notifyOnSyncError: true,
+ notifyOnSyncSuccess: true,
+ notifyOnNewRepo: false,
+ gotify: {
+ url: "https://gotify.example.com",
+ token: "my-app-token",
+ priority: 5,
+ },
+ };
+
+ await sendNotification(config, {
+ title: "Test",
+ message: "Test message",
+ type: "sync_success",
+ });
+
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ const [url, opts] = mockFetch.mock.calls[0];
+ expect(url).toBe("https://gotify.example.com/message");
+ expect(opts.headers["X-Gotify-Key"]).toBe("my-app-token");
+ });
+
test("does not throw when fetch fails", async () => {
mockFetch = mock(() => Promise.reject(new Error("Network error")));
globalThis.fetch = mockFetch as any;
@@ -140,6 +166,29 @@ describe("sendNotification", () => {
expect(mockFetch).not.toHaveBeenCalled();
});
+
+ test("skips notification when gotify token is missing", async () => {
+ const config: NotificationConfig = {
+ enabled: true,
+ provider: "gotify",
+ notifyOnSyncError: true,
+ notifyOnSyncSuccess: true,
+ notifyOnNewRepo: false,
+ gotify: {
+ url: "https://gotify.example.com",
+ token: "",
+ priority: 5,
+ },
+ };
+
+ await sendNotification(config, {
+ title: "Test",
+ message: "Test message",
+ type: "sync_success",
+ });
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
});
describe("testNotification", () => {
diff --git a/src/lib/notification-service.ts b/src/lib/notification-service.ts
index 0753e24..0e68039 100644
--- a/src/lib/notification-service.ts
+++ b/src/lib/notification-service.ts
@@ -2,6 +2,7 @@ import type { NotificationConfig } from "@/types/config";
import type { NotificationEvent } from "./providers/ntfy";
import { sendNtfyNotification } from "./providers/ntfy";
import { sendAppriseNotification } from "./providers/apprise";
+import { sendGotifyNotification } from "./providers/gotify";
import { db, configs } from "@/lib/db";
import { eq, sql } from "drizzle-orm";
import { decrypt } from "@/lib/utils/encryption";
@@ -52,6 +53,12 @@ export async function sendNotification(
return;
}
await sendAppriseNotification(config.apprise, event);
+ } else if (config.provider === "gotify") {
+ if (!config.gotify?.url || !config.gotify?.token) {
+ console.warn("[NotificationService] Gotify URL or token is not configured, skipping notification");
+ return;
+ }
+ await sendGotifyNotification(config.gotify, event);
}
} catch (error) {
console.error("[NotificationService] Failed to send notification:", error);
@@ -83,6 +90,11 @@ export async function testNotification(
return { success: false, error: "Apprise URL and token are required" };
}
await sendAppriseNotification(notificationConfig.apprise, event);
+ } else if (notificationConfig.provider === "gotify") {
+ if (!notificationConfig.gotify?.url || !notificationConfig.gotify?.token) {
+ return { success: false, error: "Gotify URL and token are required" };
+ }
+ await sendGotifyNotification(notificationConfig.gotify, event);
} else {
return { success: false, error: `Unknown provider: ${notificationConfig.provider}` };
}
@@ -166,6 +178,12 @@ export async function triggerJobNotification({
token: decrypt(decryptedConfig.apprise.token),
};
}
+ if (decryptedConfig.provider === "gotify" && decryptedConfig.gotify?.token) {
+ decryptedConfig.gotify = {
+ ...decryptedConfig.gotify,
+ token: decrypt(decryptedConfig.gotify.token),
+ };
+ }
// Build event
const repoLabel = repositoryName || organizationName || "Unknown";
diff --git a/src/lib/providers/gotify.test.ts b/src/lib/providers/gotify.test.ts
new file mode 100644
index 0000000..9868937
--- /dev/null
+++ b/src/lib/providers/gotify.test.ts
@@ -0,0 +1,106 @@
+import { describe, test, expect, beforeEach, mock } from "bun:test";
+import { sendGotifyNotification } from "./gotify";
+import type { NotificationEvent } from "./ntfy";
+import type { GotifyConfig } from "@/types/config";
+
+describe("sendGotifyNotification", () => {
+ let mockFetch: ReturnType;
+
+ beforeEach(() => {
+ mockFetch = mock(() =>
+ Promise.resolve(new Response("ok", { status: 200 }))
+ );
+ globalThis.fetch = mockFetch as any;
+ });
+
+ const baseConfig: GotifyConfig = {
+ url: "https://gotify.example.com",
+ token: "AbCdEf123456",
+ priority: 5,
+ };
+
+ const baseEvent: NotificationEvent = {
+ title: "Test Notification",
+ message: "This is a test",
+ type: "sync_success",
+ };
+
+ test("constructs correct URL from config", async () => {
+ await sendGotifyNotification(baseConfig, baseEvent);
+
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ const [url] = mockFetch.mock.calls[0];
+ expect(url).toBe("https://gotify.example.com/message");
+ });
+
+ test("strips trailing slash from URL", async () => {
+ await sendGotifyNotification(
+ { ...baseConfig, url: "https://gotify.example.com/" },
+ baseEvent
+ );
+
+ const [url] = mockFetch.mock.calls[0];
+ expect(url).toBe("https://gotify.example.com/message");
+ });
+
+ test("sends token via X-Gotify-Key header", async () => {
+ await sendGotifyNotification(baseConfig, baseEvent);
+
+ const [, opts] = mockFetch.mock.calls[0];
+ expect(opts.headers["X-Gotify-Key"]).toBe("AbCdEf123456");
+ });
+
+ test("sends title and message in JSON body", async () => {
+ await sendGotifyNotification(baseConfig, baseEvent);
+
+ const [, opts] = mockFetch.mock.calls[0];
+ const body = JSON.parse(opts.body);
+ expect(body.title).toBe("Test Notification");
+ expect(body.message).toBe("This is a test");
+ });
+
+ test("uses priority 8 for sync_error events", async () => {
+ const errorEvent: NotificationEvent = {
+ ...baseEvent,
+ type: "sync_error",
+ };
+ await sendGotifyNotification(baseConfig, errorEvent);
+
+ const [, opts] = mockFetch.mock.calls[0];
+ const body = JSON.parse(opts.body);
+ expect(body.priority).toBe(8);
+ });
+
+ test("uses config priority for non-error events", async () => {
+ await sendGotifyNotification(
+ { ...baseConfig, priority: 2 },
+ baseEvent
+ );
+
+ const [, opts] = mockFetch.mock.calls[0];
+ const body = JSON.parse(opts.body);
+ expect(body.priority).toBe(2);
+ });
+
+ test("defaults to priority 5 when not configured", async () => {
+ await sendGotifyNotification(
+ { ...baseConfig, priority: undefined as any },
+ baseEvent
+ );
+
+ const [, opts] = mockFetch.mock.calls[0];
+ const body = JSON.parse(opts.body);
+ expect(body.priority).toBe(5);
+ });
+
+ test("throws on non-200 response", async () => {
+ mockFetch = mock(() =>
+ Promise.resolve(new Response("unauthorized", { status: 401 }))
+ );
+ globalThis.fetch = mockFetch as any;
+
+ expect(
+ sendGotifyNotification(baseConfig, baseEvent)
+ ).rejects.toThrow("Gotify error: 401");
+ });
+});
diff --git a/src/lib/providers/gotify.ts b/src/lib/providers/gotify.ts
new file mode 100644
index 0000000..8e227ec
--- /dev/null
+++ b/src/lib/providers/gotify.ts
@@ -0,0 +1,17 @@
+import type { GotifyConfig } from "@/types/config";
+import type { NotificationEvent } from "./ntfy";
+
+export async function sendGotifyNotification(config: GotifyConfig, event: NotificationEvent): Promise {
+ const url = `${config.url.replace(/\/$/, "")}/message`;
+ const headers: Record = {
+ "Content-Type": "application/json",
+ "X-Gotify-Key": config.token,
+ };
+ const body = JSON.stringify({
+ title: event.title,
+ message: event.message,
+ priority: event.type === "sync_error" ? 8 : (config.priority ?? 5),
+ });
+ const resp = await fetch(url, { method: "POST", body, headers });
+ if (!resp.ok) throw new Error(`Gotify error: ${resp.status} ${await resp.text()}`);
+}
diff --git a/src/pages/api/config/index.ts b/src/pages/api/config/index.ts
index 69e84ac..ed00439 100644
--- a/src/pages/api/config/index.ts
+++ b/src/pages/api/config/index.ts
@@ -162,6 +162,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
token: encrypt(processedNotificationConfig.apprise.token),
};
}
+ // Encrypt gotify token if present
+ if (processedNotificationConfig.gotify?.token) {
+ processedNotificationConfig.gotify = {
+ ...processedNotificationConfig.gotify,
+ token: encrypt(processedNotificationConfig.gotify.token),
+ };
+ }
}
if (existingConfig) {
@@ -344,6 +351,13 @@ export const GET: APIRoute = async ({ request, locals }) => {
notificationConfig.apprise = { ...notificationConfig.apprise, token: "" };
}
}
+ if (notificationConfig.gotify?.token) {
+ try {
+ notificationConfig.gotify = { ...notificationConfig.gotify, token: decrypt(notificationConfig.gotify.token) };
+ } catch {
+ notificationConfig.gotify = { ...notificationConfig.gotify, token: "" };
+ }
+ }
}
return new Response(JSON.stringify({
diff --git a/src/types/config.ts b/src/types/config.ts
index 75760c4..b07a6d9 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -119,14 +119,21 @@ export interface AppriseConfig {
tag?: string;
}
+export interface GotifyConfig {
+ url: string;
+ token: string;
+ priority: number;
+}
+
export interface NotificationConfig {
enabled: boolean;
- provider: "ntfy" | "apprise";
+ provider: "ntfy" | "apprise" | "gotify";
notifyOnSyncError: boolean;
notifyOnSyncSuccess: boolean;
notifyOnNewRepo: boolean;
ntfy?: NtfyConfig;
apprise?: AppriseConfig;
+ gotify?: GotifyConfig;
}
export interface Config extends ConfigType {}