From ba90ec1f7ad417e663427ddf3def1aca02d54362 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 20 May 2026 19:11:46 +0200 Subject: [PATCH] feat: warn when setup JMAP URL points at a local-only host --- app/setup/page.tsx | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/app/setup/page.tsx b/app/setup/page.tsx index 761cdf54..64e9cad6 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -740,6 +740,21 @@ function ServerStep({ config, setConfig, onNext }: Pick )} + {isPrivateOrLocalHostUrl(config.jmapServerUrl) && ( +
+
+ +
+
+

+ This URL only resolves locally. +

+

+ Mail is fetched directly from the user's browser, so the JMAP URL must be reachable from anywhere users sign in - not just this machine or LAN. Use a public hostname (e.g. https://mail.example.com) in production. +

+
+
+ )} {probe && probe.url === config.jmapServerUrl && ( probe.status === 'jmap_detected' ? (
@@ -1779,6 +1794,41 @@ function isInsecureHttpUrl(url: string): boolean { return /^http:\/\//i.test(url.trim()); } +/** + * The JMAP URL is called directly from the user's browser. A URL that only + * resolves on the operator's machine or LAN (localhost, RFC1918, .local mDNS) + * works during setup but breaks for any real user. Surface a soft warning + * so the operator catches this before going live. + */ +function isPrivateOrLocalHostUrl(url: string): boolean { + const trimmed = url.trim(); + if (!trimmed) return false; + let host: string; + try { + host = new URL(trimmed).hostname.toLowerCase(); + } catch { + return false; + } + // Strip IPv6 brackets, if any. + if (host.startsWith('[') && host.endsWith(']')) { + host = host.slice(1, -1); + } + if (host === 'localhost' || host.endsWith('.localhost')) return true; + if (host.endsWith('.local')) return true; + if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true; + // IPv4 literal: only flag the well-known private/loopback/link-local ranges. + const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (v4) { + const [a, b] = [Number(v4[1]), Number(v4[2])]; + if (a === 10) return true; + if (a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + } + return false; +} + function detectInsecureContext(): boolean { if (typeof window === 'undefined') return false; if (window.location.protocol !== 'http:') return false;