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
This commit is contained in:
Marc-André Bombeck committed 2026-09-13 14:13:55 +02:00
1 parent f6a890df8a
commit 92606f6409
9 files changed
+147 -11

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -6136,6 +6136,7 @@
"rate_limited": "상대 서버에서 요청 수를 제한했어요",
"timeout": "요청 시간이 초과됐어요",
"upstream_error": "상대 서버가 오류를 반환했어요",
"upstream_rejected": "상대 서버가 메시지를 거부했어요",
"connection_failed": "연결에 실패했어요",
"not_configured": "설정되지 않았어요",
"url_not_public": "URL이 공개 엔드포인트가 아니에요",
+1
View File
@@ -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",
@@ -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(
<TestConnectionFailure
message="The other end refused the message"
upstreamStatus={400}
upstreamBody='{"error":"Bad Request","errorCode":400}'
/>,
);
expect(html).toContain('role="alert"');
expect(html).toContain("The other end refused the message (HTTP 400)");
expect(html).toContain(
"{&quot;error&quot;:&quot;Bad Request&quot;,&quot;errorCode&quot;:400}",
);
});
it("renders a markup-shaped body as characters", () => {
const html = renderToStaticMarkup(
<TestConnectionFailure
message="Upstream returned an error"
upstreamStatus={502}
upstreamBody='<img src=x onerror="alert(1)">'
/>,
);
expect(html).not.toContain("<img");
expect(html).toContain("&lt;img src=x onerror=&quot;alert(1)&quot;&gt;");
});
it("shows the SMTP code when there is no HTTP status", () => {
const html = renderToStaticMarkup(
<TestConnectionFailure message="Credentials rejected" smtpCode={535} />,
);
expect(html).toContain("Credentials rejected (SMTP 535)");
});
it("shows the sentence alone when the server sent no detail", () => {
const html = renderToStaticMarkup(
<TestConnectionFailure message="Test failed" />,
);
expect(html).toContain("Test failed");
expect(html).not.toContain("HTTP");
expect(html).not.toContain("test-connection-upstream-body");
});
});
@@ -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 (
<div role="alert" className="space-y-1">
<p className="text-destructive flex items-center gap-1.5 text-sm">
<XCircle className="size-3.5 shrink-0" />
{`${message}${code}`}
</p>
{upstreamBody ? (
<p
data-testid="test-connection-upstream-body"
className="text-foreground font-mono text-xs break-all"
>
{upstreamBody}
</p>
) : null}
</div>
);
}
export function TestConnectionButton({
@@ -39,7 +91,13 @@ export function TestConnectionButton({
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<
| { kind: "ok"; latency: number }
| { kind: "error"; errorCode: string }
| {
kind: "error";
errorCode: string;
upstreamStatus?: number;
smtpCode?: number;
upstreamBody?: string;
}
| null
>(null);
@@ -56,9 +114,20 @@ export function TestConnectionButton({
latency: json.data?.latencyMs ?? 0,
});
} else {
const meta = json.meta;
setResult({
kind: "error",
errorCode: json.meta?.errorCode ?? "generic",
errorCode: meta?.errorCode ?? "generic",
upstreamStatus:
typeof meta?.upstreamStatus === "number"
? meta.upstreamStatus
: undefined,
smtpCode:
typeof meta?.smtpCode === "number" ? meta.smtpCode : undefined,
upstreamBody:
typeof meta?.upstreamBody === "string"
? meta.upstreamBody
: undefined,
});
}
} catch {
@@ -109,13 +178,12 @@ export function TestConnectionButton({
)}
{result?.kind === "error" && (
<p
role="alert"
className="text-destructive flex items-center gap-1.5 text-sm"
>
<XCircle className="h-3.5 w-3.5" />
{describeError(result.errorCode)}
</p>
<TestConnectionFailure
message={describeError(result.errorCode)}
upstreamStatus={result.upstreamStatus}
smtpCode={result.smtpCode}
upstreamBody={result.upstreamBody}
/>
)}
</div>
);