From 99d5276cfa3c18357864995b4ac2b42202307d62 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:47:35 +0100 Subject: [PATCH] feat: add support for custom JMAP server endpoints in login and settings --- .env.example | 9 ++++++++- app/[locale]/login/page.tsx | 37 +++++++++++++++++++++++++++++++++---- app/admin/settings/page.tsx | 8 ++++++++ app/api/config/route.ts | 1 + hooks/use-config.ts | 4 ++++ lib/admin/types.ts | 1 + locales/en/common.json | 3 +++ 7 files changed, 58 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 86967030..777871b6 100644 --- a/.env.example +++ b/.env.example @@ -9,9 +9,16 @@ # App name displayed in the UI APP_NAME=Bulwark Webmail -# URL of your JMAP-compatible mail server (required) +# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set) JMAP_SERVER_URL=https://your-jmap-server.com +# Allow users to specify a custom JMAP server URL on the login form. +# When enabled, a "JMAP Server" field appears on the login page. +# Users can connect to any JMAP-compatible server. +# NOTE: External JMAP servers must include this domain in their CORS +# Access-Control-Allow-Origin header, or browser requests will be blocked. +# ALLOW_CUSTOM_JMAP_ENDPOINT=true + # ============================================================================= # Stalwart Mail Server Integration # ============================================================================= diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 8741aefc..7ea19c32 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -32,13 +32,14 @@ export default function LoginPage() { const isAddAccountMode = searchParams.get("mode") === "add-account"; const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); - const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode } = useConfig(); + const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const [formData, setFormData] = useState({ username: "", password: "", }); + const [jmapEndpoint, setJmapEndpoint] = useState(""); const [totpCode, setTotpCode] = useState(""); const [showTotpField, setShowTotpField] = useState(false); const [rememberMe, setRememberMe] = useState(false); @@ -73,6 +74,12 @@ export default function LoginPage() { } }, [appName, serverUrl]); + useEffect(() => { + if (serverUrl && !jmapEndpoint) { + setJmapEndpoint(serverUrl); + } + }, [serverUrl, jmapEndpoint]); + useEffect(() => { try { if (sessionStorage.getItem('session_expired') === 'true') { @@ -264,7 +271,7 @@ export default function LoginPage() { ); } - if (!serverUrl && !demoMode) { + if (!serverUrl && !demoMode && !allowCustomJmapEndpoint) { return (
@@ -362,7 +369,7 @@ export default function LoginPage() { sessionStorage.setItem("oauth_code_verifier", verifier); sessionStorage.setItem("oauth_state", state); - sessionStorage.setItem("oauth_server_url", serverUrl!); + sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!); if (isAddAccountMode) { sessionStorage.setItem("oauth_add_account_mode", "true"); } @@ -382,8 +389,9 @@ export default function LoginPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl; const success = await login( - serverUrl, + effectiveServerUrl, formData.username, formData.password, totpCode || undefined, @@ -749,6 +757,27 @@ export default function LoginPage() { /* Login Form */
+ {/* JMAP Endpoint field (when custom endpoints are allowed) */} + {allowCustomJmapEndpoint && ( +
+ + setJmapEndpoint(e.target.value)} + className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200" + placeholder={t("jmap_endpoint_placeholder")} + required + /> +

+ {t("jmap_endpoint_cors_hint")} +

+
+ )} + {/* Username field */}