From 15f0e499f96ae0fcc5c6c680b4623cd867d378e0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 13 Sep 2026 13:56:58 +0200
Subject: [PATCH 1/7] refactor: share the secret-shape matcher from one leaf
The idempotency cache refuses to persist a body carrying a HealthLog token or an AI provider key. The notification test routes are about to refuse echoing such text too, so the pattern moves to src/lib/secret-shape.ts instead of being written a second time. Behaviour of the cache is unchanged.
---
src/lib/__tests__/secret-shape.test.ts | 25 +++++++++++++++++++++++++
src/lib/idempotency.ts | 17 ++++-------------
src/lib/secret-shape.ts | 21 +++++++++++++++++++++
3 files changed, 50 insertions(+), 13 deletions(-)
create mode 100644 src/lib/__tests__/secret-shape.test.ts
create mode 100644 src/lib/secret-shape.ts
diff --git a/src/lib/__tests__/secret-shape.test.ts b/src/lib/__tests__/secret-shape.test.ts
new file mode 100644
index 000000000..2cdbf3d26
--- /dev/null
+++ b/src/lib/__tests__/secret-shape.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+
+import { looksSecretShaped } from "@/lib/secret-shape";
+
+describe("looksSecretShaped", () => {
+ it.each([
+ ["an access token", "token hlk_abc123"],
+ ["a refresh token", "hlr_xyz789"],
+ ["a share-link token", "see hls_def456"],
+ ["an invite token", "hlv_0a1b2c3d"],
+ ["an elevation token", "hle_0a1b2c3d"],
+ ["an OpenAI key", "echoed sk-1234567890"],
+ ["an Anthropic key", "sk-ant-api03-xyz12345"],
+ ])("matches %s", (_label, text) => {
+ expect(looksSecretShaped(text)).toBe(true);
+ });
+
+ it.each([
+ ["a word containing sk-", "task-id must not contain spaces"],
+ ["another one", "risk-management"],
+ ["a JSON error body", '{"error":"Bad Request","errorCode":400}'],
+ ])("does not match %s", (_label, text) => {
+ expect(looksSecretShaped(text)).toBe(false);
+ });
+});
diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts
index c9e2dbeb8..70e2ddcbc 100644
--- a/src/lib/idempotency.ts
+++ b/src/lib/idempotency.ts
@@ -28,6 +28,7 @@ import { isP2002 } from "@/lib/prisma-errors";
import { findActiveGrant } from "@/lib/sharing/grants";
import { decrypt, encrypt } from "@/lib/crypto";
import { createHash } from "node:crypto";
+import { looksSecretShaped } from "@/lib/secret-shape";
const TTL_MS = 24 * 60 * 60 * 1000;
/**
@@ -641,21 +642,11 @@ export function withIdempotency<
// Defence-in-depth: never persist a body that carries a freshly-issued
// bearer / refresh token or a third-party AI provider key. Auth and
// settings routes shouldn't be wrapped in withIdempotency to begin
- // with, but if a future caller forgets we refuse to leak.
- // `hlk_` = our access tokens
- // `hlr_` = our refresh tokens
- // `hls_` = clinician share-link tokens (v1.11)
- // `hlv_` = registration invite tokens (v1.16 — the admin mint
- // response is the one place the raw token appears)
- // `sk-…` = OpenAI / Anthropic keys (full token form, not the
- // raw substring — a 422 body explaining "task-id…"
- // or any other word containing `sk-` would otherwise
- // silently break idempotency for benign retries).
- const SECRET_PATTERN =
- /(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/;
+ // with, but if a future caller forgets we refuse to leak. The prefixes
+ // are listed where the matcher lives (`src/lib/secret-shape.ts`).
const cloned = response.clone();
const text = await cloned.text();
- if (!SECRET_PATTERN.test(text)) {
+ if (!looksSecretShaped(text)) {
await persistCached(ctx, response, text);
} else {
// Secret-shaped body — drop the claim so the key isn't left
diff --git a/src/lib/secret-shape.ts b/src/lib/secret-shape.ts
new file mode 100644
index 000000000..7786f7b59
--- /dev/null
+++ b/src/lib/secret-shape.ts
@@ -0,0 +1,21 @@
+/**
+ * One definition of "this text carries a credential HealthLog or a common
+ * AI provider mints", shared by every surface that refuses to store or echo
+ * such text.
+ *
+ * `hlk_` access tokens, `hlr_` refresh tokens, `hls_` clinician share-link
+ * tokens, `hlv_` registration invite tokens, `hle_` elevation tokens;
+ * `sk-…` / `sk-ant-…` OpenAI and Anthropic keys, in the full token form
+ * only: a bare `sk-` substring ("task-id", "risk-management") is not a key,
+ * and matching it would refuse benign text.
+ *
+ * It is a shape test, not a secret detector. A credential without one of
+ * these prefixes (a relay's own app token, a password) passes, so a caller
+ * that knows the secrets in play must also refuse text containing them.
+ */
+const SECRET_SHAPED =
+ /(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/;
+
+export function looksSecretShaped(text: string): boolean {
+ return SECRET_SHAPED.test(text);
+}
From 8c345f600e18971d6bbc31ecdb0dfa65854dd1b6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 13 Sep 2026 14:04:40 +0200
Subject: [PATCH 2/7] feat(notifications): keep what the relay answered on a
failed send
The webhook and ntfy senders now carry up to 200 characters of a non-2xx response body on the send outcome, with control and formatting characters removed. The body is dropped when it looks like a credential or echoes the header value, auth token, topic or a URL token. Nothing stores it; the ledger row and channel state keep the reason code as before.
Transport faults without a status now say whether they were a timeout or a refused connection, and the email sender maps SMTP failures into the same vocabulary with the reply code. Both exist so the test routes can answer with the cause instead of a bare 500.
Refs #947
---
.../__tests__/upstream-body.test.ts | 110 ++++++++++++++++
src/lib/notifications/retry-policy.ts | 16 +++
.../senders/__tests__/email.test.ts | 67 ++++++++++
.../senders/__tests__/ntfy.test.ts | 35 +++++
.../senders/__tests__/webhook.test.ts | 59 +++++++++
src/lib/notifications/senders/email.ts | 45 ++++++-
src/lib/notifications/senders/ntfy.ts | 19 +++
src/lib/notifications/senders/webhook.ts | 19 +++
src/lib/notifications/upstream-body.ts | 121 ++++++++++++++++++
9 files changed, 486 insertions(+), 5 deletions(-)
create mode 100644 src/lib/notifications/__tests__/upstream-body.test.ts
create mode 100644 src/lib/notifications/upstream-body.ts
diff --git a/src/lib/notifications/__tests__/upstream-body.test.ts b/src/lib/notifications/__tests__/upstream-body.test.ts
new file mode 100644
index 000000000..021c6c1a0
--- /dev/null
+++ b/src/lib/notifications/__tests__/upstream-body.test.ts
@@ -0,0 +1,110 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ readUpstreamBody,
+ secretsInHeaderValue,
+ secretsInUrl,
+ UPSTREAM_BODY_MAX_CHARS,
+} from "@/lib/notifications/upstream-body";
+
+function res(body: string, status = 400): Response {
+ return new Response(body, { status });
+}
+
+const RIGHT_TO_LEFT_OVERRIDE = String.fromCodePoint(0x202e);
+const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
+const BELL = String.fromCodePoint(0x07);
+
+describe("readUpstreamBody", () => {
+ it("returns a short JSON error body as it arrived", async () => {
+ const body =
+ '{"error":"Bad Request","errorCode":400,"errorDescription":"priority must be an integer"}';
+ await expect(readUpstreamBody(res(body), [])).resolves.toBe(body);
+ });
+
+ it("removes control and bidirectional characters and collapses whitespace", async () => {
+ const body = `line one\r\n\tline two ${RIGHT_TO_LEFT_OVERRIDE}evil${ZERO_WIDTH_SPACE}${BELL}`;
+ await expect(readUpstreamBody(res(body), [])).resolves.toBe(
+ "line one line two evil",
+ );
+ });
+
+ it("cuts a long body to the cap, marked with an ellipsis", async () => {
+ const result = await readUpstreamBody(res("x".repeat(5000)), []);
+ expect(Array.from(result ?? "")).toHaveLength(UPSTREAM_BODY_MAX_CHARS);
+ expect(result?.endsWith("…")).toBe(true);
+ });
+
+ it("reads at most a bounded prefix of an endless body", async () => {
+ let pulled = 0;
+ const stream = new ReadableStream({
+ pull(controller) {
+ pulled += 1;
+ controller.enqueue(new TextEncoder().encode("y".repeat(1024)));
+ },
+ });
+ const result = await readUpstreamBody(
+ new Response(stream, { status: 502 }),
+ [],
+ );
+ expect(result?.startsWith("yyyy")).toBe(true);
+ // 4 KB in 1 KB chunks: the body is abandoned after a few pulls.
+ expect(pulled).toBeLessThan(10);
+ });
+
+ it("refuses a secret-shaped body", async () => {
+ await expect(
+ readUpstreamBody(res('{"error":"bad key sk-ant-api03-abcdefgh"}'), []),
+ ).resolves.toBeUndefined();
+ });
+
+ it("refuses a body that echoes a configured secret", async () => {
+ await expect(
+ readUpstreamBody(res("token AbCdEf123 is not valid"), [
+ undefined,
+ "AbCdEf123",
+ ]),
+ ).resolves.toBeUndefined();
+ });
+
+ it("ignores a configured value too short to be a secret", async () => {
+ await expect(
+ readUpstreamBody(res("priority 5 rejected"), ["5"]),
+ ).resolves.toBe("priority 5 rejected");
+ });
+
+ it("returns nothing for an empty or unreadable body", async () => {
+ await expect(readUpstreamBody(res(" \n"), [])).resolves.toBeUndefined();
+ await expect(
+ readUpstreamBody({ ok: false, status: 400 } as Response, []),
+ ).resolves.toBeUndefined();
+ await expect(
+ readUpstreamBody(
+ {
+ text: () => Promise.reject(new Error("aborted")),
+ } as unknown as Response,
+ [],
+ ),
+ ).resolves.toBeUndefined();
+ });
+});
+
+describe("secret extraction", () => {
+ it("finds query values and userinfo in a URL", () => {
+ expect(
+ secretsInUrl(
+ "https://user:pa%20ss@gotify.example.com/message?token=Abc123",
+ ),
+ ).toEqual(["Abc123", "user", "pa ss"]);
+ expect(secretsInUrl("not a url")).toEqual([]);
+ });
+
+ it("keeps the token of a scheme-prefixed header value", () => {
+ expect(secretsInHeaderValue("Bearer tok123")).toEqual([
+ "Bearer tok123",
+ "tok123",
+ ]);
+ expect(secretsInHeaderValue("AppToken")).toEqual(["AppToken"]);
+ expect(secretsInHeaderValue(undefined)).toEqual([]);
+ });
+});
diff --git a/src/lib/notifications/retry-policy.ts b/src/lib/notifications/retry-policy.ts
index 6dbdfe8d8..958024563 100644
--- a/src/lib/notifications/retry-policy.ts
+++ b/src/lib/notifications/retry-policy.ts
@@ -42,6 +42,22 @@ export interface SendOutcome {
* instead of a bare 500.
*/
errorCode?: string;
+ /**
+ * Why a delivery failed when there is no HTTP status to say it: a timeout,
+ * a refused connection, an SMTP reply. The value is a code from the test
+ * card's vocabulary (`settings.testConnection.errors`), so a test route can
+ * forward it as `meta.errorCode` without translating. Absent when the
+ * sender cannot tell, which the test routes treat as an internal failure.
+ */
+ failureCode?: string;
+ /** SMTP reply code of a rejected send (email only). */
+ smtpCode?: number;
+ /**
+ * What the other end answered on a non-2xx, bounded and cleaned by
+ * `readUpstreamBody`, and absent when it might carry a secret. Only the
+ * test routes read it; neither the ledger nor the channel state stores it.
+ */
+ upstreamBody?: string;
}
/**
diff --git a/src/lib/notifications/senders/__tests__/email.test.ts b/src/lib/notifications/senders/__tests__/email.test.ts
index 998ec15f6..83f12cec3 100644
--- a/src/lib/notifications/senders/__tests__/email.test.ts
+++ b/src/lib/notifications/senders/__tests__/email.test.ts
@@ -170,3 +170,70 @@ describe("sendViaEmail", () => {
);
});
});
+
+describe("sendViaEmail — the failure names itself for the test button", () => {
+ it.each([
+ [
+ "an authentication refusal",
+ Object.assign(new Error("Invalid login"), {
+ code: "EAUTH",
+ responseCode: 535,
+ }),
+ "credentials_rejected",
+ 535,
+ ],
+ [
+ "a timeout",
+ Object.assign(new Error("Connection timeout"), { code: "ETIMEDOUT" }),
+ "timeout",
+ undefined,
+ ],
+ [
+ "a refused connection",
+ Object.assign(new Error("connect ECONNREFUSED"), {
+ code: "ECONNECTION",
+ }),
+ "connection_failed",
+ undefined,
+ ],
+ [
+ "a permanent SMTP rejection",
+ Object.assign(new Error("550 mailbox unavailable"), {
+ responseCode: 550,
+ }),
+ "upstream_rejected",
+ 550,
+ ],
+ [
+ "a transient SMTP rejection",
+ Object.assign(new Error("451 try later"), { responseCode: 451 }),
+ "upstream_error",
+ 451,
+ ],
+ ])("%s", async (_label, err, failureCode, smtpCode) => {
+ loadEmailConfigMock.mockReturnValue(transport);
+ sendMailMock.mockRejectedValue(err);
+
+ const result = await sendViaEmail(
+ { recipient: "you@example.com" },
+ payload(),
+ );
+
+ expect(result.ok).toBe(false);
+ expect(result.failureCode).toBe(failureCode);
+ expect(result.smtpCode).toBe(smtpCode);
+ });
+
+ it("leaves an unrecognised fault without a code", async () => {
+ loadEmailConfigMock.mockReturnValue(transport);
+ sendMailMock.mockRejectedValue(new Error("something else"));
+
+ const result = await sendViaEmail(
+ { recipient: "you@example.com" },
+ payload(),
+ );
+
+ expect(result.failureCode).toBeUndefined();
+ expect(result.smtpCode).toBeUndefined();
+ });
+});
diff --git a/src/lib/notifications/senders/__tests__/ntfy.test.ts b/src/lib/notifications/senders/__tests__/ntfy.test.ts
index 9bb4c17be..e38efa619 100644
--- a/src/lib/notifications/senders/__tests__/ntfy.test.ts
+++ b/src/lib/notifications/senders/__tests__/ntfy.test.ts
@@ -233,3 +233,38 @@ describe("sendViaNtfy", () => {
});
});
});
+
+describe("sendViaNtfy — what the server said (#947)", () => {
+ it("carries a short error body on a non-2xx", async () => {
+ safeFetchMock.mockResolvedValue(
+ new Response('{"code":40009,"http":400,"error":"invalid request"}', {
+ status: 400,
+ }),
+ );
+
+ const result = await sendViaNtfy(config, payload());
+
+ expect(result).toMatchObject({
+ ok: false,
+ statusCode: 400,
+ upstreamBody: '{"code":40009,"http":400,"error":"invalid request"}',
+ });
+ });
+
+ it("drops a body that echoes the auth token or the topic", async () => {
+ safeFetchMock.mockResolvedValueOnce(
+ new Response("token tk_secret rejected", { status: 403 }),
+ );
+ expect((await sendViaNtfy(config, payload())).upstreamBody).toBeUndefined();
+
+ safeFetchMock.mockResolvedValueOnce(
+ new Response("topic health is reserved", { status: 403 }),
+ );
+ expect((await sendViaNtfy(config, payload())).upstreamBody).toBeUndefined();
+ });
+
+ it("names a timeout", async () => {
+ safeFetchMock.mockRejectedValue(new SafeFetchError("timed out", "timeout"));
+ expect((await sendViaNtfy(config, payload())).failureCode).toBe("timeout");
+ });
+});
diff --git a/src/lib/notifications/senders/__tests__/webhook.test.ts b/src/lib/notifications/senders/__tests__/webhook.test.ts
index 255f1705e..6bec91585 100644
--- a/src/lib/notifications/senders/__tests__/webhook.test.ts
+++ b/src/lib/notifications/senders/__tests__/webhook.test.ts
@@ -281,3 +281,62 @@ describe("sendViaWebhook", () => {
});
});
});
+
+describe("sendViaWebhook — what the relay said (#947)", () => {
+ it("carries a short error body on a non-2xx", async () => {
+ safeFetchMock.mockResolvedValue(
+ new Response('{"error":"Bad Request","errorCode":400}', { status: 400 }),
+ );
+
+ const result = await sendViaWebhook(config, payload());
+
+ expect(result).toMatchObject({
+ ok: false,
+ statusCode: 400,
+ reason: "webhook_400",
+ upstreamBody: '{"error":"Bad Request","errorCode":400}',
+ });
+ // The ledger row keeps the reason only, never the body.
+ expect(recordPushAttemptMock).toHaveBeenCalledWith(
+ expect.not.objectContaining({ upstreamBody: expect.anything() }),
+ );
+ });
+
+ it("drops a body that echoes the header token or a query token", async () => {
+ safeFetchMock.mockResolvedValueOnce(
+ new Response("invalid token secret", { status: 401 }),
+ );
+ const viaHeader = await sendViaWebhook(config, payload());
+ expect(viaHeader.upstreamBody).toBeUndefined();
+
+ safeFetchMock.mockResolvedValueOnce(
+ new Response("unknown token Q1w2E3r4", { status: 401 }),
+ );
+ const viaQuery = await sendViaWebhook(
+ { url: "https://relay.example.com/message?token=Q1w2E3r4" },
+ payload(),
+ );
+ expect(viaQuery.upstreamBody).toBeUndefined();
+ });
+
+ it("names a timeout and a refused connection, and leaves an unknown fault uncoded", async () => {
+ safeFetchMock.mockRejectedValueOnce(
+ new SafeFetchError("timed out", "timeout"),
+ );
+ expect((await sendViaWebhook(config, payload())).failureCode).toBe(
+ "timeout",
+ );
+
+ safeFetchMock.mockRejectedValueOnce(
+ new SafeFetchError("ECONNREFUSED", "network"),
+ );
+ expect((await sendViaWebhook(config, payload())).failureCode).toBe(
+ "connection_failed",
+ );
+
+ safeFetchMock.mockRejectedValueOnce(new TypeError("boom"));
+ expect(
+ (await sendViaWebhook(config, payload())).failureCode,
+ ).toBeUndefined();
+ });
+});
diff --git a/src/lib/notifications/senders/email.ts b/src/lib/notifications/senders/email.ts
index 86afb748e..2b518638d 100644
--- a/src/lib/notifications/senders/email.ts
+++ b/src/lib/notifications/senders/email.ts
@@ -79,19 +79,48 @@ function classifySmtpError(err: unknown): {
hardReject: boolean;
reason: string;
message: string;
+ failureCode?: string;
+ smtpCode?: number;
} {
const message = err instanceof Error ? err.message : "smtp_send_failed";
// nodemailer attaches `responseCode` (the SMTP reply code) on protocol-level
- // rejections. 5xx = permanent (bad mailbox, relay denied) → hard reject so
- // the channel auto-disables. 4xx / connection errors = transient → retry.
+ // rejections and a `code` string on transport faults. 5xx = permanent (bad
+ // mailbox, relay denied) → hard reject so the channel auto-disables.
+ // 4xx / connection errors = transient → retry.
const responseCode =
typeof err === "object" && err !== null && "responseCode" in err
? (err as { responseCode?: number }).responseCode
: undefined;
- if (typeof responseCode === "number" && responseCode >= 500) {
- return { hardReject: true, reason: "email_smtp_5xx", message };
+ const code =
+ typeof err === "object" && err !== null && "code" in err
+ ? (err as { code?: unknown }).code
+ : undefined;
+ const smtpCode = typeof responseCode === "number" ? responseCode : undefined;
+ // The test card's vocabulary, so the test route can say which it was.
+ // Anything unrecognised stays without a code.
+ const failureCode =
+ code === "EAUTH"
+ ? "credentials_rejected"
+ : code === "ETIMEDOUT"
+ ? "timeout"
+ : code === "ECONNECTION" ||
+ code === "ESOCKET" ||
+ code === "EDNS" ||
+ code === "ECONNREFUSED"
+ ? "connection_failed"
+ : smtpCode !== undefined && smtpCode >= 500
+ ? "upstream_rejected"
+ : smtpCode !== undefined && smtpCode >= 400
+ ? "upstream_error"
+ : undefined;
+ const extras = {
+ ...(failureCode ? { failureCode } : {}),
+ ...(smtpCode !== undefined ? { smtpCode } : {}),
+ };
+ if (smtpCode !== undefined && smtpCode >= 500) {
+ return { hardReject: true, reason: "email_smtp_5xx", message, ...extras };
}
- return { hardReject: false, reason: "email_smtp_error", message };
+ return { hardReject: false, reason: "email_smtp_error", message, ...extras };
}
export async function sendViaEmail(
@@ -169,6 +198,12 @@ export async function sendViaEmail(
hardReject: classified.hardReject,
reason: classified.reason,
message: classified.message,
+ ...(classified.failureCode
+ ? { failureCode: classified.failureCode }
+ : {}),
+ ...(classified.smtpCode !== undefined
+ ? { smtpCode: classified.smtpCode }
+ : {}),
};
}
}
diff --git a/src/lib/notifications/senders/ntfy.ts b/src/lib/notifications/senders/ntfy.ts
index 1692112b8..a2fe63401 100644
--- a/src/lib/notifications/senders/ntfy.ts
+++ b/src/lib/notifications/senders/ntfy.ts
@@ -16,6 +16,10 @@ import {
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
} from "@/lib/notifications/egress-policy";
import type { OriginReason } from "@/lib/private-origin-policy";
+import {
+ readUpstreamBody,
+ secretsInUrl,
+} from "@/lib/notifications/upstream-body";
/**
* Send notification via ntfy (simple HTTP POST).
@@ -129,11 +133,19 @@ export async function sendViaNtfy(
result: "error",
reason: classified.reason,
});
+ // What the server said, for the test button. The topic is treated as a
+ // secret too: on a public ntfy server it is the only access control.
+ const upstreamBody = await readUpstreamBody(res, [
+ config.authToken,
+ config.topic,
+ ...secretsInUrl(config.serverUrl),
+ ]);
return {
ok: false,
statusCode: res.status,
hardReject: classified.hardReject,
reason: classified.reason,
+ ...(upstreamBody ? { upstreamBody } : {}),
};
} catch (err) {
const message = err instanceof Error ? err.message : "request_failed";
@@ -144,6 +156,12 @@ export async function sendViaNtfy(
const reason = policyRefused
? "ntfy_private_origin_refused"
: "ntfy_network_error";
+ const failureCode =
+ err instanceof SafeFetchError && !policyRefused
+ ? err.kind === "timeout"
+ ? "timeout"
+ : "connection_failed"
+ : undefined;
getEvent()?.addExternalCall({
service: "ntfy",
method: "sendNotification",
@@ -165,6 +183,7 @@ export async function sendViaNtfy(
...(policyRefused
? { errorCode: policyReason ?? PRIVATE_ORIGIN_NOT_APPROVED_CODE }
: {}),
+ ...(failureCode ? { failureCode } : {}),
};
}
}
diff --git a/src/lib/notifications/senders/webhook.ts b/src/lib/notifications/senders/webhook.ts
index c9d480a7e..baad20ccc 100644
--- a/src/lib/notifications/senders/webhook.ts
+++ b/src/lib/notifications/senders/webhook.ts
@@ -15,6 +15,11 @@ import {
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
} from "@/lib/notifications/egress-policy";
import type { OriginReason } from "@/lib/private-origin-policy";
+import {
+ readUpstreamBody,
+ secretsInHeaderValue,
+ secretsInUrl,
+} from "@/lib/notifications/upstream-body";
/**
* Send a notification via a generic outbound webhook (v1.17.1).
@@ -130,11 +135,18 @@ export async function sendViaWebhook(
result: "error",
reason: classified.reason,
});
+ // What the relay said, for the test button (Gotify names the field it
+ // could not bind). Refused when it echoes the header or a URL token.
+ const upstreamBody = await readUpstreamBody(res, [
+ ...secretsInHeaderValue(config.headerValue),
+ ...secretsInUrl(config.url),
+ ]);
return {
ok: false,
statusCode: res.status,
hardReject: classified.hardReject,
reason: classified.reason,
+ ...(upstreamBody ? { upstreamBody } : {}),
};
} catch (err) {
const message = err instanceof Error ? err.message : "request_failed";
@@ -147,6 +159,12 @@ export async function sendViaWebhook(
const reason = policyRefused
? "webhook_private_origin_refused"
: "webhook_network_error";
+ const failureCode =
+ err instanceof SafeFetchError && !policyRefused
+ ? err.kind === "timeout"
+ ? "timeout"
+ : "connection_failed"
+ : undefined;
getEvent()?.addExternalCall({
service: "webhook",
method: "sendNotification",
@@ -168,6 +186,7 @@ export async function sendViaWebhook(
...(policyRefused
? { errorCode: policyReason ?? PRIVATE_ORIGIN_NOT_APPROVED_CODE }
: {}),
+ ...(failureCode ? { failureCode } : {}),
};
}
}
diff --git a/src/lib/notifications/upstream-body.ts b/src/lib/notifications/upstream-body.ts
new file mode 100644
index 000000000..dbf57bad2
--- /dev/null
+++ b/src/lib/notifications/upstream-body.ts
@@ -0,0 +1,121 @@
+import { looksSecretShaped } from "@/lib/secret-shape";
+
+/** What the test card shows at most. */
+export const UPSTREAM_BODY_MAX_CHARS = 200;
+/** What is read off the wire at most, before anything is cleaned or cut. */
+const UPSTREAM_BODY_MAX_READ_BYTES = 4096;
+/** A configured value shorter than this is not treated as a secret to hide. */
+const MIN_KNOWN_SECRET_LENGTH = 4;
+
+/**
+ * C0 and C1 control characters, plus the zero-width and bidirectional
+ * formatting marks that could make the rendered text read differently from
+ * what the relay sent.
+ */
+function isControlOrFormatting(codePoint: number): boolean {
+ return (
+ codePoint <= 0x1f ||
+ (codePoint >= 0x7f && codePoint <= 0x9f) ||
+ (codePoint >= 0x200b && codePoint <= 0x200f) ||
+ (codePoint >= 0x202a && codePoint <= 0x202e) ||
+ (codePoint >= 0x2066 && codePoint <= 0x2069)
+ );
+}
+
+async function readBounded(res: Response): Promise {
+ const reader = res.body?.getReader?.();
+ if (reader) {
+ const chunks: Uint8Array[] = [];
+ let total = 0;
+ try {
+ while (total < UPSTREAM_BODY_MAX_READ_BYTES) {
+ const { done, value } = await reader.read();
+ if (done || !value) break;
+ chunks.push(value);
+ total += value.byteLength;
+ }
+ } finally {
+ // An oversized error page is abandoned, not drained.
+ await reader.cancel().catch(() => {});
+ }
+ const bytes = new Uint8Array(Math.min(total, UPSTREAM_BODY_MAX_READ_BYTES));
+ let offset = 0;
+ for (const chunk of chunks) {
+ const room = bytes.length - offset;
+ if (room <= 0) break;
+ bytes.set(chunk.subarray(0, room), offset);
+ offset += Math.min(chunk.byteLength, room);
+ }
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
+ }
+ if (typeof res.text === "function") return res.text();
+ return undefined;
+}
+
+/**
+ * Read what a relay answered on a non-2xx, in a form safe to hand back to
+ * the person who pressed the test button.
+ *
+ * Bounded twice: at most 4 KB is read off the wire, and at most 200
+ * characters survive. Control and formatting characters are removed and
+ * whitespace collapsed. The text is refused outright (undefined) when it is
+ * empty, unreadable, secret-shaped, or contains one of `knownSecrets` (the
+ * header value, the auth token, a query-string token): a relay that echoes
+ * the credential it rejected must not have that credential shown on screen.
+ *
+ * The result is for the test routes. Nothing stores it: the ledger and the
+ * channel state keep only the status and the reason code.
+ */
+export async function readUpstreamBody(
+ res: Response,
+ knownSecrets: ReadonlyArray,
+): Promise {
+ let raw: string | undefined;
+ try {
+ raw = await readBounded(res);
+ } catch {
+ return undefined;
+ }
+ if (!raw) return undefined;
+
+ const cleaned = Array.from(raw, (ch) =>
+ isControlOrFormatting(ch.codePointAt(0) ?? 0) ? " " : ch,
+ )
+ .join("")
+ .replace(/\s+/g, " ")
+ .trim();
+ if (!cleaned) return undefined;
+
+ if (looksSecretShaped(raw) || looksSecretShaped(cleaned)) return undefined;
+ for (const secret of knownSecrets) {
+ if (!secret || secret.length < MIN_KNOWN_SECRET_LENGTH) continue;
+ if (raw.includes(secret) || cleaned.includes(secret)) return undefined;
+ }
+
+ const characters = Array.from(cleaned);
+ if (characters.length <= UPSTREAM_BODY_MAX_CHARS) return cleaned;
+ return `${characters.slice(0, UPSTREAM_BODY_MAX_CHARS - 1).join("")}…`;
+}
+
+/**
+ * The secrets a URL can carry on its own: userinfo and every query value
+ * (Gotify's `?token=` form).
+ */
+export function secretsInUrl(url: string): string[] {
+ try {
+ const parsed = new URL(url);
+ const found = [...parsed.searchParams.values()];
+ if (parsed.username) found.push(decodeURIComponent(parsed.username));
+ if (parsed.password) found.push(decodeURIComponent(parsed.password));
+ return found;
+ } catch {
+ return [];
+ }
+}
+
+/** A header value and, for the `` form, the token alone. */
+export function secretsInHeaderValue(value: string | undefined): string[] {
+ if (!value) return [];
+ const parts = value.trim().split(/\s+/);
+ return parts.length > 1 ? [value, parts[parts.length - 1]] : [value];
+}
From f6a890df8a31d4da34437314b7c48930cd2da7fd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 13 Sep 2026 14:10:35 +0200
Subject: [PATCH 3/7] fix(notifications): answer a failed channel test with its
cause
The webhook, ntfy and email test routes turned every delivery failure into a bare 500, so the settings card could only say "Test failed" while the server knew the relay had answered 400. A failure at the other end, or on the way to it, is now a 502 with meta.errorCode, the upstream HTTP status or SMTP reply code, and for webhook and ntfy what the relay answered when that is short and holds no credential. The codes reuse the test card's vocabulary; upstream_rejected is new, for a relay that accepted the connection and refused the payload. The private-origin 422 is unchanged, and only a failure the server cannot name stays a 500.
The guard that pinned the bare 500 now asserts the opposite, with the reason in its name.
The admin fan-out test reports the same code and status per channel, and tests the webhook and email channels, which it used to list as an unknown channel type.
Refs #947
---
docs/api/openapi.yaml | 72 +++++++--
.../test/__tests__/route.test.ts | 138 +++++++++++++++++-
src/app/api/admin/notifications/test/route.ts | 88 +++++++++--
.../__tests__/email-test-route.test.ts | 131 +++++++++++++++++
.../__tests__/private-origin-routes.test.ts | 107 ++++++++++++--
src/app/api/settings/email/test/route.ts | 9 +-
src/app/api/settings/ntfy/test/route.ts | 9 +-
src/app/api/settings/webhook/test/route.ts | 10 +-
.../__tests__/test-delivery-failure.test.ts | 74 ++++++++++
.../notifications/test-delivery-failure.ts | 109 ++++++++++++++
src/lib/openapi/routes/settings.ts | 36 ++++-
11 files changed, 744 insertions(+), 39 deletions(-)
create mode 100644 src/app/api/settings/__tests__/email-test-route.test.ts
create mode 100644 src/lib/notifications/__tests__/test-delivery-failure.test.ts
create mode 100644 src/lib/notifications/test-delivery-failure.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index fb07ebd4e..90730c227 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:
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/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/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/lib/notifications/__tests__/test-delivery-failure.test.ts b/src/lib/notifications/__tests__/test-delivery-failure.test.ts
new file mode 100644
index 000000000..69be323fb
--- /dev/null
+++ b/src/lib/notifications/__tests__/test-delivery-failure.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ codeForUpstreamStatus,
+ testFailureDetail,
+ testFailureSentence,
+} from "@/lib/notifications/test-delivery-failure";
+
+describe("codeForUpstreamStatus", () => {
+ it.each([
+ [400, "upstream_rejected"],
+ [401, "credentials_rejected"],
+ [403, "credentials_rejected"],
+ [404, "endpoint_not_found"],
+ [405, "upstream_rejected"],
+ [409, "upstream_rejected"],
+ [410, "endpoint_not_found"],
+ [413, "upstream_rejected"],
+ [415, "upstream_rejected"],
+ [422, "upstream_rejected"],
+ [429, "rate_limited"],
+ [500, "upstream_error"],
+ [502, "upstream_error"],
+ [301, "redirected"],
+ [308, "redirected"],
+ ])("%i → %s", (status, code) => {
+ expect(codeForUpstreamStatus(status)).toBe(code);
+ });
+});
+
+describe("testFailureDetail", () => {
+ it("prefers the HTTP status over any transport code", () => {
+ expect(
+ testFailureDetail({
+ ok: false,
+ statusCode: 400,
+ failureCode: "timeout",
+ upstreamBody: "bad",
+ }),
+ ).toEqual({
+ errorCode: "upstream_rejected",
+ upstreamStatus: 400,
+ upstreamBody: "bad",
+ });
+ });
+
+ it("uses the sender's code when there is no status", () => {
+ expect(
+ testFailureDetail({
+ ok: false,
+ failureCode: "upstream_rejected",
+ smtpCode: 550,
+ }),
+ ).toEqual({ errorCode: "upstream_rejected", smtpCode: 550 });
+ });
+
+ it("names nothing for an unnamed failure", () => {
+ expect(testFailureDetail({ ok: false, reason: "x" })).toEqual({});
+ });
+});
+
+describe("testFailureSentence", () => {
+ it("states the status, and falls back to a plain sentence for an unlisted code", () => {
+ expect(
+ testFailureSentence("The webhook", {
+ errorCode: "upstream_rejected",
+ upstreamStatus: 400,
+ }),
+ ).toBe("The webhook answered HTTP 400.");
+ expect(
+ testFailureSentence("The webhook", { errorCode: "something_new" }),
+ ).toBe("The webhook test failed.");
+ });
+});
diff --git a/src/lib/notifications/test-delivery-failure.ts b/src/lib/notifications/test-delivery-failure.ts
new file mode 100644
index 000000000..1728f1678
--- /dev/null
+++ b/src/lib/notifications/test-delivery-failure.ts
@@ -0,0 +1,109 @@
+import { apiError } from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import type { SendOutcome } from "@/lib/notifications/retry-policy";
+
+/**
+ * The code for an HTTP status a relay answered, in the test card's
+ * vocabulary (`settings.testConnection.errors`). The same split the
+ * Nightscout and wearable test routes use, plus two the relays need: a 3xx
+ * (safeFetch never follows a redirect) and a 4xx that is not about the
+ * credentials or the path, where the relay accepted the connection and
+ * refused the payload.
+ */
+export function codeForUpstreamStatus(status: number): string {
+ if (status === 401 || status === 403) return "credentials_rejected";
+ if (status === 404 || status === 410) return "endpoint_not_found";
+ if (status === 429) return "rate_limited";
+ if (status >= 500) return "upstream_error";
+ if (status >= 300 && status < 400) return "redirected";
+ return "upstream_rejected";
+}
+
+export interface TestFailureDetail {
+ /** Absent only for a failure the sender could not name: an internal fault. */
+ errorCode?: string;
+ upstreamStatus?: number;
+ smtpCode?: number;
+ upstreamBody?: string;
+}
+
+/** What a failed send outcome says, without the private-origin refusal. */
+export function testFailureDetail(result: SendOutcome): TestFailureDetail {
+ if (result.statusCode !== undefined) {
+ return {
+ errorCode: codeForUpstreamStatus(result.statusCode),
+ upstreamStatus: result.statusCode,
+ ...(result.upstreamBody ? { upstreamBody: result.upstreamBody } : {}),
+ };
+ }
+ if (result.failureCode) {
+ return {
+ errorCode: result.failureCode,
+ ...(result.smtpCode !== undefined ? { smtpCode: result.smtpCode } : {}),
+ };
+ }
+ return {};
+}
+
+/** One English sentence for the envelope's `error`, the card translates the code. */
+export function testFailureSentence(
+ label: string,
+ detail: TestFailureDetail,
+): string {
+ if (detail.upstreamStatus !== undefined) {
+ return `${label} answered HTTP ${detail.upstreamStatus}.`;
+ }
+ const smtp =
+ detail.smtpCode !== undefined ? ` (SMTP ${detail.smtpCode})` : "";
+ switch (detail.errorCode) {
+ case "timeout":
+ return `${label} did not answer in time.`;
+ case "connection_failed":
+ return `Could not connect to ${label}.`;
+ case "credentials_rejected":
+ return `${label} rejected the credentials${smtp}.`;
+ case "upstream_rejected":
+ return `${label} refused the message${smtp}.`;
+ case "upstream_error":
+ return `${label} reported a temporary error${smtp}.`;
+ default:
+ return `${label} test failed.`;
+ }
+}
+
+/**
+ * The test routes' answer to a failed send that was not a private-origin
+ * refusal (those keep their own 422 arm).
+ *
+ * The other end, or the way to it, failed: 502 with `meta.errorCode`, the
+ * upstream status or SMTP code, and what the relay said when it is short
+ * and holds no secret. A failure the sender could not name is an internal
+ * fault and stays a 500 with `fallbackMessage`.
+ */
+export function answerTestDeliveryFailure(
+ result: SendOutcome,
+ opts: {
+ channel: "webhook" | "ntfy" | "email";
+ label: string;
+ fallbackMessage: string;
+ },
+): Response {
+ const detail = testFailureDetail(result);
+ annotate({
+ action: { name: `settings.${opts.channel}.test` },
+ meta: {
+ success: false,
+ [`${opts.channel}_test_code`]: detail.errorCode ?? "internal",
+ ...(detail.upstreamStatus !== undefined
+ ? { [`${opts.channel}_test_status`]: detail.upstreamStatus }
+ : {}),
+ ...(detail.smtpCode !== undefined
+ ? { [`${opts.channel}_test_smtp_code`]: detail.smtpCode }
+ : {}),
+ },
+ });
+ if (!detail.errorCode) {
+ return apiError(opts.fallbackMessage, 500);
+ }
+ return apiError(testFailureSentence(opts.label, detail), 502, { ...detail });
+}
diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts
index 7b948794f..03264d50a 100644
--- a/src/lib/openapi/routes/settings.ts
+++ b/src/lib/openapi/routes/settings.ts
@@ -685,7 +685,7 @@ export const settingsPaths: NonNullable = {
summary: "Send a test email to the saved recipient",
description:
"Fires one message through the operator's SMTP transport to the address saved on this account. Takes no body. Rate-limited 5 per 5 minutes per user.\n\n" +
- "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.",
+ "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 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:
@@ -697,6 +697,16 @@ export const settingsPaths: NonNullable = {
},
},
...stdResponses,
+ "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: errorEnvelope } },
+ },
+ "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: errorEnvelope } },
+ },
},
},
},
@@ -705,7 +715,7 @@ export const settingsPaths: NonNullable = {
tags: ["Notifications"],
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.",
+ "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 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:
@@ -717,6 +727,16 @@ export const settingsPaths: NonNullable = {
},
},
...stdResponses,
+ "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: errorEnvelope } },
+ },
+ "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: errorEnvelope } },
+ },
"422": {
description:
"The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.",
@@ -751,7 +771,7 @@ export const settingsPaths: NonNullable = {
tags: ["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.",
+ "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 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:
@@ -763,6 +783,16 @@ export const settingsPaths: NonNullable = {
},
},
...stdResponses,
+ "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: errorEnvelope } },
+ },
+ "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: errorEnvelope } },
+ },
"422": {
description:
"The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.",
From 92606f64099f18b7527e149b2609a17a6f320db4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 13 Sep 2026 14:13:55 +0200
Subject: [PATCH 4/7] feat(settings): show the status and reply of a failed
channel test
The test-connection callout now appends the HTTP status or SMTP reply code the route reports, and shows what the relay answered on a line beneath it. The reply is rendered as a React text child, so markup in it is shown as characters.
Adds the upstream_rejected message in all seven locales.
Refs #947
---
messages/de.json | 1 +
messages/en.json | 1 +
messages/es.json | 1 +
messages/fr.json | 1 +
messages/it.json | 1 +
messages/ko.json | 1 +
messages/pl.json | 1 +
.../test-connection-failure.test.tsx | 61 +++++++++++++
.../settings/test-connection-button.tsx | 90 ++++++++++++++++---
9 files changed, 147 insertions(+), 11 deletions(-)
create mode 100644 src/components/settings/__tests__/test-connection-failure.test.tsx
diff --git a/messages/de.json b/messages/de.json
index 3a48fb6f1..b5ac510ae 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -6136,6 +6136,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 b2a37305f..e54cffb55 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6136,6 +6136,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 e3f4ca56c..1ca650b2a 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -6136,6 +6136,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 48fc47a50..aba616f1d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -6136,6 +6136,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 0e97ed7be..834d5da3d 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -6136,6 +6136,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 d09db31aa..48722e917 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -6136,6 +6136,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 3c84cbc85..4689c536b 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -6136,6 +6136,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/components/settings/__tests__/test-connection-failure.test.tsx b/src/components/settings/__tests__/test-connection-failure.test.tsx
new file mode 100644
index 000000000..2002a70ac
--- /dev/null
+++ b/src/components/settings/__tests__/test-connection-failure.test.tsx
@@ -0,0 +1,61 @@
+/**
+ * The test-connection failure callout (#947).
+ *
+ * SSR-only, like the rest of the component suite: the callout is hook-free so
+ * it renders here without a provider. What is pinned is what a person reads:
+ * the translated sentence, the status the other end answered, and the relay's
+ * own words as text, never as markup.
+ */
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+
+import { TestConnectionFailure } from "../test-connection-button";
+
+describe("TestConnectionFailure", () => {
+ it("shows the sentence with the HTTP status and what the relay said", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain('role="alert"');
+ expect(html).toContain("The other end refused the message (HTTP 400)");
+ expect(html).toContain(
+ "{"error":"Bad Request","errorCode":400}",
+ );
+ });
+
+ it("renders a markup-shaped body as characters", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).not.toContain(" {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain("Credentials rejected (SMTP 535)");
+ });
+
+ it("shows the sentence alone when the server sent no detail", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain("Test failed");
+ expect(html).not.toContain("HTTP");
+ expect(html).not.toContain("test-connection-upstream-body");
+ });
+});
diff --git a/src/components/settings/test-connection-button.tsx b/src/components/settings/test-connection-button.tsx
index 503b53079..e741a6233 100644
--- a/src/components/settings/test-connection-button.tsx
+++ b/src/components/settings/test-connection-button.tsx
@@ -11,7 +11,10 @@ import { apiFetchRaw } from "@/lib/api/api-fetch";
* Shared "Test connection" UX for the settings integrations + notifications
* sections (A8-UI). POSTs to the given endpoint, expects a `{ data, error,
* meta }` envelope, and surfaces success (latency) or a translated
- * `meta.errorCode` callout.
+ * `meta.errorCode` callout. When the route reports what the other end
+ * answered (`meta.upstreamStatus` / `meta.smtpCode` / `meta.upstreamBody`,
+ * the notification test routes since #947), the callout shows the status and
+ * the relay's own words beneath it, as plain text.
*
* Endpoints are user-scoped, so the button intentionally does NOT send an
* Idempotency-Key — each click probes the upstream live.
@@ -27,7 +30,56 @@ export interface TestConnectionButtonProps {
interface TestResponse {
data?: { latencyMs?: number; ok?: boolean; sent?: number };
error?: string;
- meta?: { errorCode?: string };
+ meta?: {
+ errorCode?: string;
+ upstreamStatus?: number;
+ smtpCode?: number;
+ upstreamBody?: string;
+ };
+}
+
+export interface TestConnectionFailureProps {
+ /** The translated error sentence. */
+ message: string;
+ upstreamStatus?: number;
+ smtpCode?: number;
+ /** What the other end answered, already bounded and cleaned by the server. */
+ upstreamBody?: string;
+}
+
+/**
+ * The failure callout. Hook-free so it renders without a provider. The
+ * relay's body is a React text child, never markup: whatever it contains is
+ * shown as characters.
+ */
+export function TestConnectionFailure({
+ message,
+ upstreamStatus,
+ smtpCode,
+ upstreamBody,
+}: TestConnectionFailureProps) {
+ const code =
+ typeof upstreamStatus === "number"
+ ? ` (HTTP ${upstreamStatus})`
+ : typeof smtpCode === "number"
+ ? ` (SMTP ${smtpCode})`
+ : "";
+ return (
+