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 ( +
+

+ + {`${message}${code}`} +

+ {upstreamBody ? ( +

+ {upstreamBody} +

+ ) : null} +
+ ); } 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" && ( -

- - {describeError(result.errorCode)} -

+ )} );