diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index fb07ebd4e..71260c858 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -12354,7 +12354,8 @@ paths:
Refuses with 400 when the instance has no SMTP transport, when the account has no email channel, or when the
- channel carries no recipient — three distinguishable prose messages on one status. A transport failure is a 500.
+ channel carries no recipient — three distinguishable prose messages on one status. A failure at the mail server
+ or on the way to it is a 502 that names its cause; only a failure the server cannot name is a 500.
responses:
"200":
description: The provider accepted the message. Not proof it was delivered.
@@ -12365,6 +12366,21 @@ paths:
"401": *a1
"422": *a4
"429": *a5
+ "500":
+ description: The send failed in a way the server could not name. An internal fault, not a statement about the mail server.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "502":
+ description: "The mail server, or the way to it, failed. `meta.errorCode` names the cause: `credentials_rejected` (the
+ server refused the SMTP login), `upstream_rejected` (a permanent 5xx rejection, e.g. an unknown mailbox or
+ relay denied), `upstream_error` (a temporary 4xx rejection), `timeout`, or `connection_failed`.
+ `meta.smtpCode` carries the SMTP reply code when the mail server sent one."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
/api/settings/ntfy/test:
post:
tags:
@@ -12372,9 +12388,10 @@ paths:
summary: Send a test message to the saved ntfy topic
description: Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per
5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server
- URL or topic; a publish failure is a 500. A server on a private network is refused with 422 and `meta.errorCode`
- = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`;
- the test and the scheduled delivery take the same decision.
+ URL or topic. A failure at the ntfy server or on the way to it is a 502 that carries the upstream status and,
+ when safe, what the server answered; only a failure the server cannot name is a 500. A server on a private
+ network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its
+ exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision.
responses:
"200":
description: The provider accepted the message. Not proof it was delivered.
@@ -12392,6 +12409,24 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
+ "500":
+ description: The send failed in a way the server could not name. An internal fault, not a statement about the ntfy server.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "502":
+ description: "The ntfy server, or the way to it, failed. `meta.errorCode` names the cause: `upstream_rejected` (the
+ other end refused the payload, e.g. 400/409/415/422), `credentials_rejected` (401/403), `endpoint_not_found`
+ (404/410), `rate_limited` (429), `upstream_error` (5xx), `redirected` (3xx, never followed), `timeout`, or
+ `connection_failed`. `meta.upstreamStatus` carries the HTTP status whenever one arrived, and
+ `meta.upstreamBody` up to 200 characters of what the other end answered, with control characters removed; it
+ is omitted when the body was empty or could carry a credential (a token-shaped string, or the saved header
+ value, auth token, topic or URL token)."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
/api/settings/telegram/test:
post:
tags:
@@ -12421,11 +12456,12 @@ paths:
- Notifications
summary: Send a test payload to the saved webhook
description: Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per
- user. Refuses with 400 when no webhook channel is saved or the saved config has no URL; a delivery failure is a
- 500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal
- host. A URL on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless
- the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take
- the same decision.
+ user. Refuses with 400 when no webhook channel is saved or the saved config has no URL. A failure at the relay
+ or on the way to it is a 502 that carries the upstream status and, when safe, what the relay answered; only a
+ failure the server cannot name is a 500. The URL was checked against the SSRF floor when it was saved, so a test
+ cannot be used to reach an internal host. A URL on a private network is refused with 422 and `meta.errorCode` =
+ `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the
+ test and the scheduled delivery take the same decision.
responses:
"200":
description: The provider accepted the message. Not proof it was delivered.
@@ -12443,6 +12479,24 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
+ "500":
+ description: The send failed in a way the server could not name. An internal fault, not a statement about the relay.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "502":
+ description: "The relay, or the way to it, failed. `meta.errorCode` names the cause: `upstream_rejected` (the other end
+ refused the payload, e.g. 400/409/415/422), `credentials_rejected` (401/403), `endpoint_not_found`
+ (404/410), `rate_limited` (429), `upstream_error` (5xx), `redirected` (3xx, never followed), `timeout`, or
+ `connection_failed`. `meta.upstreamStatus` carries the HTTP status whenever one arrived, and
+ `meta.upstreamBody` up to 200 characters of what the other end answered, with control characters removed; it
+ is omitted when the body was empty or could carry a credential (a token-shaped string, or the saved header
+ value, auth token, topic or URL token)."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
/api/settings/data:
delete:
tags:
@@ -25863,6 +25917,14 @@ components:
maxLength: 500
- type: string
const: ""
+ format:
+ description: Body shape. `generic` sends HealthLog's own JSON envelope; `gotify` sends the body Gotify's `POST /message`
+ expects, with an integer priority. Omitted keeps the stored choice, which is `generic` for a config saved
+ before the choice existed.
+ type: string
+ enum:
+ - generic
+ - gotify
enabled:
type: boolean
required:
@@ -25872,7 +25934,7 @@ components:
re-checked at dispatch time; a private origin passes both only when the operator listed it in
`NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason
as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this
- endpoint."
+ endpoint. An omitted `format` keeps the stored body shape."
DeleteAccountRequest:
type: object
properties:
@@ -57447,11 +57509,18 @@ components:
hasHeaderValue:
type: boolean
description: Whether a header value (the shared secret) is stored. The value itself is never returned.
+ format:
+ type: string
+ enum:
+ - generic
+ - gotify
+ description: Body shape the webhook sends. `generic` when no channel row exists or the config predates the choice.
required:
- enabled
- url
- headerName
- hasHeaderValue
+ - format
additionalProperties: false
description: Stored generic-webhook channel config, with the secret header value reduced to a presence flag.
PutWebhookSettingsResponse:
diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md
index a3dd06de1..e0416e86c 100644
--- a/docs/self-hosting/notifications.md
+++ b/docs/self-hosting/notifications.md
@@ -207,6 +207,73 @@ Home Assistant on your own network needs the operator grant described in
the next section. The webhook carries the event's urgency flag so you can
route urgent events differently.
+### What the webhook sends
+
+Every event is one `POST` with `Content-Type: application/json` and the
+one header you configured. It goes to the URL exactly as you saved it:
+nothing is added to the path or the query. The body depends on the
+**Payload format** you choose on the card. The examples below are what the
+Test button sends.
+
+**Generic JSON** is the default, and what every webhook saved before the
+choice existed keeps sending:
+
+```json
+{
+ "title": "HealthLog Test",
+ "message": "HealthLog: Connection successful! Webhook notifications are active.",
+ "eventType": "SYSTEM_ALERT",
+ "priority": "default"
+}
+```
+
+- `title` and `message` are plain text. HTML tags are stripped, and routine
+ reminders lose their decorative emoji.
+- `eventType` is the event name, for routing rules. With discreet cycle
+ notifications on, a cycle event arrives as `reminder`, and its title and
+ message are masked as they are on every other channel.
+- `priority` is a word: `urgent` for an event flagged urgent, `high` for
+ medication reminders, `default` for everything else.
+
+**Gotify** sends the body Gotify's `POST /message` endpoint expects:
+
+```json
+{
+ "title": "HealthLog Test",
+ "message": "HealthLog: Connection successful! Webhook notifications are active.",
+ "priority": 5,
+ "extras": {
+ "client::display": { "contentType": "text/plain" },
+ "healthlog::event": { "type": "SYSTEM_ALERT" }
+ }
+}
+```
+
+- `priority` is an integer: `5` for most events, `8` for medication
+ reminders, `10` for urgent events. The Gotify Android app plays a sound
+ from 4 and shows a heads-up banner from 8, so routine events are audible
+ and reminders pop up on screen.
+- `extras` tells Gotify clients to show the message as plain text and
+ carries the event name, which is `reminder` for a discreet cycle event.
+
+The generic body does not work with Gotify. Gotify reads `priority` as a
+number, so the word `default` makes it answer `400 Bad Request`.
+
+### Sending to Gotify
+
+1. In Gotify, create an application and copy its token.
+2. On the Webhook card, set **URL** to your Gotify address ending in
+ `/message`, for example `https://gotify.example.com/message`.
+3. Set **Header name** to `X-Gotify-Key` and **Header value** to the
+ application token. Gotify also accepts the token as `?token=` on the URL,
+ but in a header it stays out of proxy access logs.
+4. Set **Payload format** to **Gotify**, save, and press Test.
+
+If the test fails, the card shows the status Gotify answered and, when it is
+short, Gotify's own error text. A `401` means the token is wrong. A `400`
+means Gotify could not read the body; check that the format is set to
+Gotify. A Gotify on your own network also needs the operator grant below.
+
## Notification targets on a private network
Every webhook and ntfy target is resolved at send time and refused when
diff --git a/messages/de.json b/messages/de.json
index 3c70d965c..ae2269901 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "Die vollständige URL für den POST. Muss öffentlich erreichbar sein.",
"webhookHeaderName": "Header-Name (optional)",
"webhookHeaderValue": "Header-Wert (optional)",
+ "webhookFormat": "Nachrichtenformat",
+ "webhookFormatGeneric": "Generisches JSON",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Nutze die URL, die auf /message endet, und trage das App-Token als Header X-Gotify-Key ein.",
"email": "E-Mail",
"emailDescription": "Warnungen und Erinnerungen per E-Mail empfangen.",
"emailEnable": "E-Mail aktivieren",
@@ -6136,6 +6140,7 @@
"rate_limited": "Vom Upstream rate-limitiert",
"timeout": "Request hat das Zeitlimit überschritten",
"upstream_error": "Upstream hat einen Fehler gemeldet",
+ "upstream_rejected": "Die Gegenstelle hat die Nachricht abgelehnt",
"connection_failed": "Verbindung fehlgeschlagen",
"not_configured": "Nicht konfiguriert",
"url_not_public": "URL ist kein öffentlicher Endpunkt",
diff --git a/messages/en.json b/messages/en.json
index e558f8892..80cee6b17 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "The full URL to POST to. Must be publicly reachable.",
"webhookHeaderName": "Header name (optional)",
"webhookHeaderValue": "Header value (optional)",
+ "webhookFormat": "Payload format",
+ "webhookFormatGeneric": "Generic JSON",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Use the URL ending in /message and put the application token in the header X-Gotify-Key.",
"email": "Email",
"emailDescription": "Receive alerts and reminders by email.",
"emailEnable": "Enable email",
@@ -6136,6 +6140,7 @@
"rate_limited": "Rate-limited by the upstream",
"timeout": "Request timed out",
"upstream_error": "Upstream returned an error",
+ "upstream_rejected": "The other end refused the message",
"connection_failed": "Connection failed",
"not_configured": "Not configured",
"url_not_public": "URL is not a public endpoint",
diff --git a/messages/es.json b/messages/es.json
index a42c28ad0..6f14b4c6c 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "La URL completa para el POST. Debe ser accesible públicamente.",
"webhookHeaderName": "Nombre de cabecera (opcional)",
"webhookHeaderValue": "Valor de cabecera (opcional)",
+ "webhookFormat": "Formato del mensaje",
+ "webhookFormatGeneric": "JSON genérico",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Usa la URL que termina en /message y pon el token de la aplicación en la cabecera X-Gotify-Key.",
"email": "Correo electrónico",
"emailDescription": "Recibe alertas y recordatorios por correo electrónico.",
"emailEnable": "Activar correo electrónico",
@@ -6136,6 +6140,7 @@
"rate_limited": "Limitado por el servidor remoto",
"timeout": "La solicitud agotó el tiempo de espera",
"upstream_error": "El servidor remoto devolvió un error",
+ "upstream_rejected": "El servidor remoto rechazó el mensaje",
"connection_failed": "Falló la conexión",
"not_configured": "No configurado",
"url_not_public": "La URL no es un endpoint público",
diff --git a/messages/fr.json b/messages/fr.json
index cc4fed784..7c960e462 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "L’URL complète pour le POST. Doit être accessible publiquement.",
"webhookHeaderName": "Nom d’en-tête (facultatif)",
"webhookHeaderValue": "Valeur d’en-tête (facultatif)",
+ "webhookFormat": "Format du message",
+ "webhookFormatGeneric": "JSON générique",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Utilisez l’URL se terminant par /message et placez le jeton de l’application dans l’en-tête X-Gotify-Key.",
"email": "E-mail",
"emailDescription": "Recevoir les alertes et les rappels par e-mail.",
"emailEnable": "Activer l’e-mail",
@@ -6136,6 +6140,7 @@
"rate_limited": "Limité par le service en amont",
"timeout": "Délai de la requête dépassé",
"upstream_error": "Le service en amont a renvoyé une erreur",
+ "upstream_rejected": "Le service distant a refusé le message",
"connection_failed": "Échec de la connexion",
"not_configured": "Non configuré",
"url_not_public": "L’URL n’est pas un point de terminaison public",
diff --git a/messages/it.json b/messages/it.json
index 3dc9470e5..a4a87713e 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "L’URL completo per il POST. Deve essere raggiungibile pubblicamente.",
"webhookHeaderName": "Nome intestazione (opzionale)",
"webhookHeaderValue": "Valore intestazione (opzionale)",
+ "webhookFormat": "Formato del messaggio",
+ "webhookFormatGeneric": "JSON generico",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Usa l’URL che termina con /message e inserisci il token dell’applicazione nell’intestazione X-Gotify-Key.",
"email": "Email",
"emailDescription": "Ricevi avvisi e promemoria via email.",
"emailEnable": "Attiva email",
@@ -6136,6 +6140,7 @@
"rate_limited": "Limitato dal servizio upstream",
"timeout": "Richiesta scaduta",
"upstream_error": "Il servizio upstream ha restituito un errore",
+ "upstream_rejected": "Il servizio remoto ha rifiutato il messaggio",
"connection_failed": "Connessione non riuscita",
"not_configured": "Non configurato",
"url_not_public": "L’URL non è un endpoint pubblico",
diff --git a/messages/ko.json b/messages/ko.json
index a8a6a3f8f..bcdfa0806 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "POST를 보낼 전체 URL이에요. 외부에서 접근할 수 있어야 해요.",
"webhookHeaderName": "헤더 이름 (선택)",
"webhookHeaderValue": "헤더 값 (선택)",
+ "webhookFormat": "메시지 형식",
+ "webhookFormatGeneric": "일반 JSON",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "/message로 끝나는 URL을 쓰고, 앱 토큰은 X-Gotify-Key 헤더에 넣어 주세요.",
"email": "이메일",
"emailDescription": "경고와 알림을 이메일로 받아요.",
"emailEnable": "이메일 켜기",
@@ -6136,6 +6140,7 @@
"rate_limited": "상대 서버에서 요청 수를 제한했어요",
"timeout": "요청 시간이 초과됐어요",
"upstream_error": "상대 서버가 오류를 반환했어요",
+ "upstream_rejected": "상대 서버가 메시지를 거부했어요",
"connection_failed": "연결에 실패했어요",
"not_configured": "설정되지 않았어요",
"url_not_public": "URL이 공개 엔드포인트가 아니에요",
diff --git a/messages/pl.json b/messages/pl.json
index a87b93e0b..62a72e546 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "Pełny adres URL dla żądania POST. Musi być publicznie dostępny.",
"webhookHeaderName": "Nazwa nagłówka (opcjonalnie)",
"webhookHeaderValue": "Wartość nagłówka (opcjonalnie)",
+ "webhookFormat": "Format wiadomości",
+ "webhookFormatGeneric": "Ogólny JSON",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Użyj adresu URL kończącego się na /message, a token aplikacji wpisz w nagłówku X-Gotify-Key.",
"email": "E-mail",
"emailDescription": "Otrzymuj alerty i przypomnienia e-mailem.",
"emailEnable": "Włącz e-mail",
@@ -6136,6 +6140,7 @@
"rate_limited": "Ograniczone przez usługę nadrzędną",
"timeout": "Przekroczono limit czasu żądania",
"upstream_error": "Usługa nadrzędna zwróciła błąd",
+ "upstream_rejected": "Usługa docelowa odrzuciła wiadomość",
"connection_failed": "Połączenie nie powiodło się",
"not_configured": "Nieskonfigurowano",
"url_not_public": "Adres URL nie jest publicznym punktem końcowym",
diff --git a/src/app/api/admin/notifications/test/__tests__/route.test.ts b/src/app/api/admin/notifications/test/__tests__/route.test.ts
index 295e44f25..259a35461 100644
--- a/src/app/api/admin/notifications/test/__tests__/route.test.ts
+++ b/src/app/api/admin/notifications/test/__tests__/route.test.ts
@@ -65,6 +65,12 @@ vi.mock("@/lib/notifications/senders/web-push", () => ({
vi.mock("@/lib/notifications/senders/apns", () => ({
sendViaApns: vi.fn(),
}));
+vi.mock("@/lib/notifications/senders/webhook", () => ({
+ sendViaWebhook: vi.fn(),
+}));
+vi.mock("@/lib/notifications/senders/email", () => ({
+ sendViaEmail: vi.fn(),
+}));
vi.mock("@/lib/i18n/server-translator", () => ({
getServerTranslator: vi.fn(() => ({
@@ -78,6 +84,8 @@ import { requireAdmin } from "@/lib/api-handler";
import { sendViaApns } from "@/lib/notifications/senders/apns";
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
+import { sendViaWebhook } from "@/lib/notifications/senders/webhook";
+import { sendViaEmail } from "@/lib/notifications/senders/email";
const ADMIN_USER_ID = "admin-1";
const ADMIN_CTX = {
@@ -92,7 +100,7 @@ const ADMIN_CTX = {
interface FakeChannelRow {
id: string;
userId: string;
- type: "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS";
+ type: "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS" | "WEBHOOK" | "EMAIL";
enabled: boolean;
config: string;
preferences: Array<{ eventType: string; enabled: boolean }>;
@@ -119,6 +127,10 @@ interface ResultEnvelope {
channel: string;
success: boolean;
error?: string;
+ errorCode?: string;
+ upstreamStatus?: number;
+ smtpCode?: number;
+ upstreamBody?: string;
}>;
};
}
@@ -299,3 +311,127 @@ describe("POST /api/admin/notifications/test — preserved branches", () => {
});
});
});
+
+describe("POST /api/admin/notifications/test — a failure keeps its cause (#947)", () => {
+ function channel(
+ type: FakeChannelRow["type"],
+ config: string,
+ ): FakeChannelRow {
+ return {
+ id: `ch-${type}`,
+ userId: ADMIN_USER_ID,
+ type,
+ enabled: true,
+ config,
+ preferences: [],
+ };
+ }
+
+ it("carries the ntfy status and code instead of a bare sentence", async () => {
+ vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
+ channel("NTFY", 'enc({"topic":"hl-test"})'),
+ ] as never);
+ vi.mocked(sendViaNtfy).mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: "ntfy_400",
+ statusCode: 400,
+ });
+
+ const body = (await (await POST()).json()) as ResultEnvelope;
+
+ expect(body.data.results[0]).toMatchObject({
+ channel: "NTFY",
+ success: false,
+ errorCode: "upstream_rejected",
+ upstreamStatus: 400,
+ });
+ expect(body.data.results[0].error).toContain("HTTP 400");
+ });
+
+ it("tests the webhook channel rather than calling it unknown", async () => {
+ vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
+ channel("WEBHOOK", 'enc({"url":"https://relay.example.com/message"})'),
+ ] as never);
+ vi.mocked(sendViaWebhook).mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: "webhook_400",
+ statusCode: 400,
+ upstreamBody: "priority must be an integer",
+ });
+
+ const body = (await (await POST()).json()) as ResultEnvelope;
+
+ expect(sendViaWebhook).toHaveBeenCalledWith(
+ { url: "https://relay.example.com/message" },
+ expect.objectContaining({ eventType: "SYSTEM_ALERT" }),
+ );
+ expect(body.data.results[0]).toMatchObject({
+ channel: "WEBHOOK",
+ success: false,
+ errorCode: "upstream_rejected",
+ upstreamStatus: 400,
+ upstreamBody: "priority must be an integer",
+ });
+ });
+
+ it("tests the email channel and forwards the SMTP code", async () => {
+ vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
+ channel("EMAIL", 'enc({"recipient":"admin@example.com"})'),
+ ] as never);
+ vi.mocked(sendViaEmail).mockResolvedValue({
+ ok: false,
+ hardReject: true,
+ reason: "email_smtp_5xx",
+ failureCode: "upstream_rejected",
+ smtpCode: 550,
+ });
+
+ const body = (await (await POST()).json()) as ResultEnvelope;
+
+ expect(body.data.results[0]).toMatchObject({
+ channel: "EMAIL",
+ success: false,
+ errorCode: "upstream_rejected",
+ smtpCode: 550,
+ });
+ expect(body.data.results[0].error).toContain("SMTP 550");
+ });
+
+ it("forwards a private-origin refusal with its own code", async () => {
+ vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
+ channel("WEBHOOK", 'enc({"url":"http://10.0.0.5/message"})'),
+ ] as never);
+ vi.mocked(sendViaWebhook).mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: "webhook_private_origin_refused",
+ errorCode: "private_origin_not_approved",
+ });
+
+ const body = (await (await POST()).json()) as ResultEnvelope;
+
+ expect(body.data.results[0]).toMatchObject({
+ channel: "WEBHOOK",
+ success: false,
+ errorCode: "private_origin_not_approved",
+ });
+ });
+
+ it("reports a delivered webhook and email as successes", async () => {
+ vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
+ channel("WEBHOOK", 'enc({"url":"https://relay.example.com"})'),
+ channel("EMAIL", 'enc({"recipient":"admin@example.com"})'),
+ ] as never);
+ vi.mocked(sendViaWebhook).mockResolvedValue({ ok: true, statusCode: 200 });
+ vi.mocked(sendViaEmail).mockResolvedValue({ ok: true });
+
+ const body = (await (await POST()).json()) as ResultEnvelope;
+
+ expect(body.data.results).toEqual([
+ { channel: "WEBHOOK", success: true },
+ { channel: "EMAIL", success: true },
+ ]);
+ });
+});
diff --git a/src/app/api/admin/notifications/test/route.ts b/src/app/api/admin/notifications/test/route.ts
index 33492125f..0bf282198 100644
--- a/src/app/api/admin/notifications/test/route.ts
+++ b/src/app/api/admin/notifications/test/route.ts
@@ -7,9 +7,19 @@ import { sendViaTelegram } from "@/lib/notifications/senders/telegram";
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
import { sendViaApns } from "@/lib/notifications/senders/apns";
+import { sendViaWebhook } from "@/lib/notifications/senders/webhook";
+import { sendViaEmail } from "@/lib/notifications/senders/email";
+import type { SendOutcome } from "@/lib/notifications/retry-policy";
+import {
+ testFailureDetail,
+ testFailureSentence,
+ type TestFailureDetail,
+} from "@/lib/notifications/test-delivery-failure";
import type {
TelegramChannelConfig,
NtfyChannelConfig,
+ WebhookChannelConfig,
+ EmailChannelConfig,
ChannelType,
NotificationPayload,
} from "@/lib/notifications/types";
@@ -62,6 +72,39 @@ async function ensureTelegramChannel(userId: string): Promise {
return true;
}
+type TestResult = {
+ channel: string;
+ success: boolean;
+ error?: string;
+} & TestFailureDetail;
+
+/**
+ * A failed HTTP or SMTP send, with the code, status and SMTP reply code the
+ * user-facing test routes answer with, instead of a sentence that drops
+ * them. A private-origin refusal forwards its own code.
+ */
+function failedSend(
+ channel: string,
+ label: string,
+ result: SendOutcome,
+ context?: string,
+): TestResult {
+ const detail: TestFailureDetail = result.errorCode
+ ? { errorCode: result.errorCode }
+ : testFailureDetail(result);
+ const cause = result.errorCode
+ ? `refused by the private-origin policy (${result.errorCode})`
+ : detail.errorCode
+ ? testFailureSentence(label, detail)
+ : (result.reason ?? "send failed");
+ return {
+ channel,
+ success: false,
+ error: context ? `${cause} (${context})` : cause,
+ ...detail,
+ };
+}
+
function isLocale(value: string | null | undefined): value is Locale {
return (
value === "de" ||
@@ -121,11 +164,7 @@ export const POST = apiHandler(async () => {
message: t("notifications.admin.testNotificationBody"),
};
- const results: Array<{
- channel: string;
- success: boolean;
- error?: string;
- }> = [];
+ const results: TestResult[] = [];
for (const channel of channels) {
const pref = channel.preferences[0];
@@ -166,11 +205,40 @@ export const POST = apiHandler(async () => {
const ntfyResult = await sendViaNtfy(config, payload);
success = ntfyResult.ok;
if (!success) {
- results.push({
- channel: "NTFY",
- success: false,
- error: `Send failed (topic: ${config.topic})`,
- });
+ results.push(
+ failedSend(
+ "NTFY",
+ "The ntfy server",
+ ntfyResult,
+ `topic: ${config.topic}`,
+ ),
+ );
+ continue;
+ }
+ break;
+ }
+ // The generic webhook and email are first-class channels in the
+ // dispatcher; without these arms they reported "Unknown channel type".
+ case "WEBHOOK": {
+ const config = JSON.parse(
+ decrypt(channel.config),
+ ) as WebhookChannelConfig;
+ const webhookResult = await sendViaWebhook(config, payload);
+ success = webhookResult.ok;
+ if (!success) {
+ results.push(failedSend("WEBHOOK", "The webhook", webhookResult));
+ continue;
+ }
+ break;
+ }
+ case "EMAIL": {
+ const config = JSON.parse(
+ decrypt(channel.config),
+ ) as EmailChannelConfig;
+ const emailResult = await sendViaEmail(config, payload);
+ success = emailResult.ok;
+ if (!success) {
+ results.push(failedSend("EMAIL", "The mail server", emailResult));
continue;
}
break;
diff --git a/src/app/api/settings/__tests__/email-test-route.test.ts b/src/app/api/settings/__tests__/email-test-route.test.ts
new file mode 100644
index 000000000..2bf5e0684
--- /dev/null
+++ b/src/app/api/settings/__tests__/email-test-route.test.ts
@@ -0,0 +1,131 @@
+/**
+ * POST /api/settings/email/test — a failed send names its cause.
+ *
+ * Before this the route answered every failure with a bare 500, so the card
+ * read "Test failed" whether the mail server refused the login, timed out,
+ * or rejected the recipient.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/db", () => ({
+ prisma: { notificationChannel: { findUnique: vi.fn() } },
+}));
+vi.mock("@/lib/crypto", () => ({
+ decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
+}));
+const sendViaEmailMock = vi.fn();
+vi.mock("@/lib/notifications/senders/email", () => ({
+ sendViaEmail: (...args: unknown[]) => sendViaEmailMock(...args),
+}));
+vi.mock("@/lib/notifications/senders/email-config", () => ({
+ isEmailConfigured: () => true,
+}));
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn().mockResolvedValue({
+ allowed: true,
+ remaining: 4,
+ resetAt: Date.now() + 60_000,
+ }),
+ rateLimitHeaders: () => ({}),
+}));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { POST } from "../email/test/route";
+import { prisma } from "@/lib/db";
+import { getSession } from "@/lib/auth/session";
+
+const SESSION_OK = {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "user-1", username: "testuser", role: "USER" as const },
+};
+
+function post(): Promise {
+ return (POST as (request: Request) => Promise)(
+ new Request("http://localhost/api/settings/email/test", {
+ method: "POST",
+ }),
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
+ config: 'encrypted:{"recipient":"you@example.com"}',
+ } as never);
+});
+
+describe("POST /api/settings/email/test", () => {
+ it.each([
+ ["credentials_rejected", 535, "rejected the credentials (SMTP 535)"],
+ ["upstream_rejected", 550, "refused the message (SMTP 550)"],
+ ])(
+ "answers an SMTP rejection with 502, %s and the reply code",
+ async (failureCode, smtpCode, sentence) => {
+ sendViaEmailMock.mockResolvedValue({
+ ok: false,
+ hardReject: smtpCode >= 500,
+ reason: "email_smtp_5xx",
+ failureCode,
+ smtpCode,
+ });
+
+ const response = await post();
+
+ expect(response.status).toBe(502);
+ const json = await response.json();
+ expect(json.meta).toEqual({ errorCode: failureCode, smtpCode });
+ expect(json.error).toContain(sentence);
+ },
+ );
+
+ it("answers a timeout with 502 and the timeout code", async () => {
+ sendViaEmailMock.mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: "email_smtp_error",
+ failureCode: "timeout",
+ });
+
+ const response = await post();
+
+ expect(response.status).toBe(502);
+ await expect(response.json()).resolves.toMatchObject({
+ meta: { errorCode: "timeout" },
+ });
+ });
+
+ it("keeps a 500 for a failure the sender could not name", async () => {
+ sendViaEmailMock.mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: "email_smtp_error",
+ });
+
+ const response = await post();
+
+ expect(response.status).toBe(500);
+ const json = await response.json();
+ expect(json.meta).toBeUndefined();
+ });
+
+ it("answers a delivered test with 200", async () => {
+ sendViaEmailMock.mockResolvedValue({ ok: true });
+
+ const response = await post();
+
+ expect(response.status).toBe(200);
+ });
+});
diff --git a/src/app/api/settings/__tests__/private-origin-routes.test.ts b/src/app/api/settings/__tests__/private-origin-routes.test.ts
index 4bc8d5b77..7825c97b3 100644
--- a/src/app/api/settings/__tests__/private-origin-routes.test.ts
+++ b/src/app/api/settings/__tests__/private-origin-routes.test.ts
@@ -8,7 +8,9 @@
* of a generic "Invalid data", and a listed literal address saves although
* the plain public floor would refuse it. The test routes forward the
* sender's own refusal as a 422 with the same code instead of the bare 500
- * that made the reporter read the wide-event log to learn it was policy.
+ * that made the reporter read the wide-event log to learn it was policy, and
+ * answer a relay's error status with a 502 that carries the status and what
+ * the relay said.
*
* The last block is the structural half: the test button and the dispatcher
* must reach the SAME sender function, so the decision cannot differ between
@@ -351,20 +353,99 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself",
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
- ])("%s: a plain delivery failure is still a 500", async (path, sender) => {
- sender.mockResolvedValue({
- ok: false,
- hardReject: false,
- reason: `${path}_503`,
- statusCode: 503,
- });
+ ])(
+ "%s: a relay that answered 400 is a 502 naming the status and what it said, not a bare 500 (#947: the card could only say Test failed)",
+ async (path, sender) => {
+ sender.mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: `${path}_400`,
+ statusCode: 400,
+ upstreamBody: '{"error":"Bad Request","errorCode":400}',
+ });
- const response = await post(path);
+ const response = await post(path);
- expect(response.status).toBe(500);
- const json = await response.json();
- expect(json.meta).toBeUndefined();
- });
+ expect(response.status).toBe(502);
+ const json = await response.json();
+ expect(json.meta).toEqual({
+ errorCode: "upstream_rejected",
+ upstreamStatus: 400,
+ upstreamBody: '{"error":"Bad Request","errorCode":400}',
+ });
+ expect(json.error).toContain("HTTP 400");
+ },
+ );
+
+ it.each([
+ [401, "credentials_rejected"],
+ [403, "credentials_rejected"],
+ [404, "endpoint_not_found"],
+ [410, "endpoint_not_found"],
+ [415, "upstream_rejected"],
+ [422, "upstream_rejected"],
+ [429, "rate_limited"],
+ [503, "upstream_error"],
+ [307, "redirected"],
+ ])(
+ "webhook: an upstream %i maps to %s, without a body when the sender dropped it",
+ async (status, code) => {
+ sendViaWebhookMock.mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: `webhook_${status}`,
+ statusCode: status,
+ });
+
+ const response = await post("webhook");
+
+ expect(response.status).toBe(502);
+ const json = await response.json();
+ expect(json.meta).toEqual({ errorCode: code, upstreamStatus: status });
+ },
+ );
+
+ it.each([
+ ["webhook", sendViaWebhookMock],
+ ["ntfy", sendViaNtfyMock],
+ ])(
+ "%s: a timeout without a status is a 502 with the timeout code",
+ async (path, sender) => {
+ sender.mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: `${path}_network_error`,
+ failureCode: "timeout",
+ });
+
+ const response = await post(path);
+
+ expect(response.status).toBe(502);
+ await expect(response.json()).resolves.toMatchObject({
+ meta: { errorCode: "timeout" },
+ });
+ },
+ );
+
+ it.each([
+ ["webhook", sendViaWebhookMock],
+ ["ntfy", sendViaNtfyMock],
+ ])(
+ "%s: only a failure the sender could not name stays a 500",
+ async (path, sender) => {
+ sender.mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: `${path}_network_error`,
+ });
+
+ const response = await post(path);
+
+ expect(response.status).toBe(500);
+ const json = await response.json();
+ expect(json.meta).toBeUndefined();
+ },
+ );
it.each([
["webhook", sendViaWebhookMock],
diff --git a/src/app/api/settings/__tests__/webhook-format-route.test.ts b/src/app/api/settings/__tests__/webhook-format-route.test.ts
new file mode 100644
index 000000000..d20ba38b8
--- /dev/null
+++ b/src/app/api/settings/__tests__/webhook-format-route.test.ts
@@ -0,0 +1,194 @@
+/**
+ * The webhook payload format at the settings route (#947).
+ *
+ * Absent means generic, so a config saved before the choice existed keeps
+ * sending the body it always sent. Only the Gotify choice is written, an
+ * omitted format keeps the stored one, and the GET reports the resolved
+ * value rather than leaving the client to guess the default.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ notificationChannel: {
+ findUnique: vi.fn(),
+ updateMany: vi.fn(),
+ upsert: vi.fn(),
+ },
+ },
+}));
+vi.mock("@/lib/crypto", () => ({
+ encrypt: vi.fn((value: string) => `encrypted:${value}`),
+ decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
+}));
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn().mockResolvedValue({
+ allowed: true,
+ remaining: 4,
+ resetAt: Date.now() + 60_000,
+ }),
+ rateLimitHeaders: () => ({}),
+}));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { GET, PUT } from "../webhook/route";
+import { prisma } from "@/lib/db";
+import { getSession } from "@/lib/auth/session";
+
+const SESSION_OK = {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "user-1", username: "testuser", role: "USER" as const },
+};
+
+type Route = (request: Request) => Promise;
+
+function put(body: unknown): Promise {
+ return (PUT as Route)(
+ new Request("http://localhost/api/settings/webhook", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ }),
+ );
+}
+
+function get(): Promise {
+ return (GET as Route)(new Request("http://localhost/api/settings/webhook"));
+}
+
+function stored(): string {
+ const call = vi.mocked(prisma.notificationChannel.upsert).mock.calls[0][0];
+ return call.create.config as string;
+}
+
+function existing(config: Record) {
+ vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
+ enabled: true,
+ config: `encrypted:${JSON.stringify(config)}`,
+ } as never);
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(
+ null as never,
+ );
+ vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
+});
+
+describe("PUT /api/settings/webhook — payload format", () => {
+ it("stores the Gotify choice", async () => {
+ const response = await put({
+ url: "https://gotify.example.com/message",
+ headerName: "X-Gotify-Key",
+ headerValue: "AbCdEfGh123",
+ format: "gotify",
+ enabled: true,
+ });
+
+ expect(response.status).toBe(200);
+ expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toEqual({
+ url: "https://gotify.example.com/message",
+ headerName: "X-Gotify-Key",
+ headerValue: "AbCdEfGh123",
+ format: "gotify",
+ });
+ });
+
+ it("writes nothing for generic, so the stored config keeps its old shape", async () => {
+ await put({
+ url: "https://relay.example.com/hook",
+ format: "generic",
+ enabled: true,
+ });
+
+ expect(stored()).toBe('encrypted:{"url":"https://relay.example.com/hook"}');
+ });
+
+ it("keeps a stored Gotify choice when the body omits the field", async () => {
+ existing({
+ url: "https://gotify.example.com/message",
+ headerValue: "AbCdEfGh123",
+ format: "gotify",
+ });
+
+ await put({
+ url: "https://gotify.example.com/message",
+ headerValue: "NewToken456",
+ enabled: true,
+ });
+
+ expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toMatchObject({
+ headerValue: "NewToken456",
+ format: "gotify",
+ });
+ });
+
+ it("switches a Gotify channel back to generic when asked", async () => {
+ existing({ url: "https://gotify.example.com/message", format: "gotify" });
+
+ await put({
+ url: "https://gotify.example.com/message",
+ format: "generic",
+ enabled: true,
+ });
+
+ expect(stored()).toBe(
+ 'encrypted:{"url":"https://gotify.example.com/message"}',
+ );
+ });
+
+ it("refuses a format it does not know", async () => {
+ const response = await put({
+ url: "https://relay.example.com/hook",
+ format: "discord",
+ enabled: true,
+ });
+
+ expect(response.status).toBe(422);
+ expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
+ });
+});
+
+describe("GET /api/settings/webhook — payload format", () => {
+ it("reports generic for a config saved before the choice existed", async () => {
+ existing({ url: "https://relay.example.com/hook" });
+
+ const json = await (await get()).json();
+
+ expect(json.data.format).toBe("generic");
+ });
+
+ it("reports the stored Gotify choice", async () => {
+ existing({ url: "https://gotify.example.com/message", format: "gotify" });
+
+ const json = await (await get()).json();
+
+ expect(json.data.format).toBe("gotify");
+ });
+
+ it("reports generic when no channel exists", async () => {
+ const json = await (await get()).json();
+
+ expect(json.data).toEqual({
+ enabled: false,
+ url: "",
+ headerName: "",
+ hasHeaderValue: false,
+ format: "generic",
+ });
+ });
+});
diff --git a/src/app/api/settings/email/test/route.ts b/src/app/api/settings/email/test/route.ts
index 4e40c8b9a..e6c1ecaa0 100644
--- a/src/app/api/settings/email/test/route.ts
+++ b/src/app/api/settings/email/test/route.ts
@@ -7,6 +7,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { isEmailConfigured } from "@/lib/notifications/senders/email-config";
+import { answerTestDeliveryFailure } from "@/lib/notifications/test-delivery-failure";
/**
* POST: send a test email to the configured recipient.
@@ -46,7 +47,13 @@ export const POST = apiHandler(async () => {
});
if (!result.ok) {
- return apiError("Failed to send test email", 500);
+ // The mail server, or the way to it, failed: 502 with the code and the
+ // SMTP reply code. Only an unnamed internal fault is a 500.
+ return answerTestDeliveryFailure(result, {
+ channel: "email",
+ label: "The mail server",
+ fallbackMessage: "Failed to send test email",
+ });
}
annotate({
diff --git a/src/app/api/settings/ntfy/test/route.ts b/src/app/api/settings/ntfy/test/route.ts
index b260f1643..feeaff9ea 100644
--- a/src/app/api/settings/ntfy/test/route.ts
+++ b/src/app/api/settings/ntfy/test/route.ts
@@ -6,6 +6,7 @@ import type { NtfyChannelConfig } from "@/lib/notifications/types";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
+import { answerTestDeliveryFailure } from "@/lib/notifications/test-delivery-failure";
/**
* POST: Send a test notification via ntfy.
@@ -58,7 +59,13 @@ export const POST = apiHandler(async () => {
{ errorCode: result.errorCode },
);
}
- return apiError("Failed to send test message", 500);
+ // The server, or the way to it, failed: 502 with the upstream status
+ // and what it said (#947). Only an unnamed internal fault is a 500.
+ return answerTestDeliveryFailure(result, {
+ channel: "ntfy",
+ label: "The ntfy server",
+ fallbackMessage: "Failed to send test message",
+ });
}
annotate({ action: { name: "settings.ntfy.test" }, meta: { success: true } });
diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts
index 1f1b072b3..49cccdec3 100644
--- a/src/app/api/settings/webhook/route.ts
+++ b/src/app/api/settings/webhook/route.ts
@@ -20,6 +20,7 @@ import { encrypt, decrypt } from "@/lib/crypto";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
+import type { WebhookChannelConfig } from "@/lib/notifications/types";
/**
* The save-time schema evaluates the same policy the sender does: the public
@@ -61,22 +62,23 @@ export const GET = apiHandler(async () => {
url: "",
headerName: "",
hasHeaderValue: false,
+ format: "generic",
});
}
- const config = JSON.parse(decrypt(channel.config)) as {
- url: string;
- headerName?: string;
- headerValue?: string;
- };
+ const config = JSON.parse(
+ decrypt(channel.config),
+ ) as Partial;
annotate({ action: { name: "settings.webhook.get" } });
return apiSuccess({
enabled: channel.enabled,
- url: config.url,
+ url: config.url ?? "",
headerName: config.headerName ?? "",
hasHeaderValue: !!config.headerValue,
+ // A config saved before the choice existed carries no format: generic.
+ format: config.format === "gotify" ? "gotify" : "generic",
});
});
@@ -166,7 +168,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
return apiValidationError("Invalid data", issues, 422);
}
- const { url, headerName, headerValue, enabled } = parsed.data;
+ const { url, headerName, headerValue, format, enabled } = parsed.data;
if (enabled && !url) {
return apiError("Webhook URL is required when the webhook is enabled", 422);
@@ -174,24 +176,30 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// Preserve an existing header value when the client sends an empty one (the
// GET path never returns the secret, so a save round-trip would otherwise
- // wipe it). A non-empty value replaces it.
+ // wipe it). A non-empty value replaces it. An omitted format keeps the
+ // stored one too, so a client that does not know the field cannot flip a
+ // Gotify channel back to the generic body by saving.
let nextHeaderValue = headerValue || undefined;
- if (!nextHeaderValue) {
+ let nextFormat = format;
+ if (!nextHeaderValue || nextFormat === undefined) {
const existing = await prisma.notificationChannel.findUnique({
where: { userId_type: { userId: user.id, type: "WEBHOOK" } },
});
if (existing) {
- const prev = JSON.parse(decrypt(existing.config)) as {
- headerValue?: string;
- };
- nextHeaderValue = prev.headerValue || undefined;
+ const prev = JSON.parse(
+ decrypt(existing.config),
+ ) as Partial;
+ if (!nextHeaderValue) nextHeaderValue = prev.headerValue || undefined;
+ if (nextFormat === undefined) nextFormat = prev.format;
}
}
+ // Absent means generic, so only the Gotify choice is written.
const config = JSON.stringify({
url: url || "",
...(headerName ? { headerName } : {}),
...(nextHeaderValue ? { headerValue: nextHeaderValue } : {}),
+ ...(nextFormat === "gotify" ? { format: "gotify" } : {}),
});
const encryptedConfig = encrypt(config);
@@ -207,7 +215,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
update: { enabled, config: encryptedConfig },
});
- annotate({ action: { name: "settings.webhook.update" }, meta: { enabled } });
+ annotate({
+ action: { name: "settings.webhook.update" },
+ meta: { enabled, format: nextFormat === "gotify" ? "gotify" : "generic" },
+ });
return apiSuccess({ saved: true });
});
diff --git a/src/app/api/settings/webhook/test/route.ts b/src/app/api/settings/webhook/test/route.ts
index cc6bfa6dc..7021e2bfe 100644
--- a/src/app/api/settings/webhook/test/route.ts
+++ b/src/app/api/settings/webhook/test/route.ts
@@ -6,6 +6,7 @@ import type { WebhookChannelConfig } from "@/lib/notifications/types";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
+import { answerTestDeliveryFailure } from "@/lib/notifications/test-delivery-failure";
/**
* POST: send a test notification via the configured generic webhook.
@@ -57,7 +58,14 @@ export const POST = apiHandler(async () => {
{ errorCode: result.errorCode },
);
}
- return apiError("Failed to send test message", 500);
+ // The relay, or the way to it, failed: 502 with the upstream status and
+ // what it said, so the card can show "the relay answered 400" instead
+ // of "Test failed" (#947). Only an unnamed internal fault is a 500.
+ return answerTestDeliveryFailure(result, {
+ channel: "webhook",
+ label: "The webhook",
+ fallbackMessage: "Failed to send test message",
+ });
}
annotate({
diff --git a/src/components/settings/__tests__/notification-channel-cards.test.tsx b/src/components/settings/__tests__/notification-channel-cards.test.tsx
index a7cda1b36..81499de41 100644
--- a/src/components/settings/__tests__/notification-channel-cards.test.tsx
+++ b/src/components/settings/__tests__/notification-channel-cards.test.tsx
@@ -154,4 +154,27 @@ describe("notification channel cards", () => {
);
},
);
+
+ it("webhook card offers the payload format and sends it with the full save (#947)", async () => {
+ const html = render();
+
+ expect(html).toContain('id="webhook-format"');
+ expect(html).toMatch(/