diff --git a/CHANGELOG.md b/CHANGELOG.md index 794c51a4..4579685d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## 1.6.2 (2026-05-06) + +### Features + +- **Plugins**: Hot-reload and dev-folder loading for live plugin development +- **Plugins**: On-demand `src/` bundling via esbuild +- **Plugins**: New `http:fetch` permission and `httpOrigins` manifest field +- **Plugins**: `onBeforeEmailSend` hook with `fromEmail` exposed on `OutgoingEmail` +- **Plugins**: Project `EmailReadView` for the email-banner slot and expose auth results +- **Plugins**: Ingest icon, banner, and screenshots from the source repo +- **Plugins**: Restrict plugin and theme install/uninstall to the admin dashboard +- **Mail**: Multi-server JMAP support +- **Settings**: Fulltext search across the settings sidebar +- **Settings**: Sub-result rows with highlight in settings search +- **Settings**: Surface plugin settings as search sub-results +- **Settings**: Remove experimental tags from themes, plugins, and sender favicons +- **Viewer**: Redesigned external-mail banner above attachments +- **Calendar**: Calendar invitation banner expands on row click +- **Calendar**: Calendar invitation banner is now collapsible + +### Fixes + +- **Admin**: Collapse admin panel into a single tabbed page +- **Plugins**: Inline plugin configure panel to avoid dev-mode hang +- **Plugins**: Resolve `PLUGIN_DEV_DIR` plugins in admin config route +- **Plugins**: Add missing body type assertion in `createPluginAPI` fetch options +- **Plugins**: Propagate `settingsSchema` +- **Settings**: Highlight plugin and theme cards in search results +- **Settings**: Open plugin card on first click of a setting sub-result +- **Settings**: Drop ghost sub-results from account and language search +- **Settings**: Improve search highlight styling +- **Viewer**: Show notification banners above attachments +- **Viewer**: Rework S/MIME banner to match calendar invitation +- **Viewer**: Close PDF preview on Escape before email viewer +- **Viewer**: Render PDF previews via `` with `blob:` in object-src CSP (#253) +- **Calendar**: Align invitation icon with sender avatar column +- **Calendar**: Fix invitation picker clipping (#250) +- **Auth**: Read `activeAccountId` from authStore in account selectors +- **UI**: Adjust toast item border radius and progress bar styles +- **UI**: Remove fly-in animation from context menu submenus +- **i18n**: Add missing Czech flag icon + +### i18n + +- Add missing translation keys across 15 locales + ## 1.6.1 (2026-05-04) ### Features diff --git a/Dockerfile b/Dockerfile index e91af29a..20084be2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ ENV NEXT_TELEMETRY_DISABLED=1 ARG NEXT_PUBLIC_BASE_PATH= ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH # Commit SHA shown in the About screen. .dockerignore excludes .git, so -# `git rev-parse` inside the build can't find it — CI must pass it in. +# `git rev-parse` inside the build can't find it - CI must pass it in. ARG GIT_COMMIT=unknown ENV GIT_COMMIT=$GIT_COMMIT RUN npx next build --webpack diff --git a/README.md b/README.md index 0694d9a8..eb830e07 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) [![Version](https://img.shields.io/badge/version-1.6.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) +[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/) diff --git a/VERSION b/VERSION index 2eda823f..308b6faa 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.1 \ No newline at end of file +1.6.2 \ No newline at end of file diff --git a/app/[locale]/auth/callback/page.tsx b/app/[locale]/auth/callback/page.tsx index 8032d75a..b03e2313 100644 --- a/app/[locale]/auth/callback/page.tsx +++ b/app/[locale]/auth/callback/page.tsx @@ -43,6 +43,7 @@ function OAuthCallbackInner() { const codeVerifier = sessionStorage.getItem("oauth_code_verifier"); const serverUrl = sessionStorage.getItem("oauth_server_url"); + const serverId = sessionStorage.getItem("oauth_server_id") || undefined; if (!codeVerifier || !serverUrl) { setError("missing_params"); @@ -52,12 +53,13 @@ function OAuthCallbackInner() { const prefix = getPathPrefix(params.locale as string); const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; - loginWithOAuth(serverUrl, code, codeVerifier, redirectUri) + loginWithOAuth(serverUrl, code, codeVerifier, redirectUri, serverId) .then((success) => { if (success) { sessionStorage.removeItem("oauth_state"); sessionStorage.removeItem("oauth_code_verifier"); sessionStorage.removeItem("oauth_server_url"); + sessionStorage.removeItem("oauth_server_id"); sessionStorage.removeItem("oauth_add_account_mode"); let redirectTo = `${prefix}/${params.locale}`; try { diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 5bdd1938..2b974a63 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -18,6 +18,14 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; import { useUpdateStore, selectBanner } from "@/stores/update-store"; +import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers"; + +function findServerByDomain(servers: PublicJmapServerEntry[], email: string | undefined): PublicJmapServerEntry | undefined { + if (!email || !email.includes("@")) return undefined; + const domain = email.split("@")[1]?.trim().toLowerCase(); + if (!domain) return undefined; + return servers.find((s) => (s.domains ?? []).some((d) => d.toLowerCase() === domain)); +} const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; @@ -109,7 +117,7 @@ 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, allowCustomJmapEndpoint } = useConfig(); + const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const [formData, setFormData] = useState({ @@ -117,6 +125,18 @@ export default function LoginPage() { password: "", }); const [jmapEndpoint, setJmapEndpoint] = useState(""); + const [selectedServerId, setSelectedServerId] = useState(null); + const [domainAutoLocked, setDomainAutoLocked] = useState(false); + + const hasServerList = jmapServers.length > 0; + const selectedServer = hasServerList + ? jmapServers.find((s) => s.id === selectedServerId) ?? jmapServers[0] + : undefined; + + // Effective values: per-server overrides win, then global config. + const serverUrl = selectedServer?.url || configuredServerUrl; + const effectiveOauthClientId = selectedServer?.oauth?.clientId || globalOauthClientId; + const effectiveOauthIssuerUrl = selectedServer?.oauth?.issuerUrl || globalOauthIssuerUrl; const [totpCode, setTotpCode] = useState(""); const [showTotpField, setShowTotpField] = useState(false); const [rememberMe, setRememberMe] = useState(false); @@ -157,6 +177,27 @@ export default function LoginPage() { } }, [serverUrl, jmapEndpoint]); + // Initialize selected server when the server list arrives. Picks the first + // entry; the auto-pick effect below may override based on the email domain. + useEffect(() => { + if (!hasServerList) return; + if (selectedServerId && jmapServers.some((s) => s.id === selectedServerId)) return; + setSelectedServerId(jmapServers[0].id); + }, [hasServerList, jmapServers, selectedServerId]); + + // Auto-pick by email domain. Locks the dropdown to the matched server until + // the user clears the email or types a domain we don't recognize. + useEffect(() => { + if (!jmapServerAutoPickByDomain || !hasServerList) return; + const match = findServerByDomain(jmapServers, formData.username); + if (match) { + if (selectedServerId !== match.id) setSelectedServerId(match.id); + setDomainAutoLocked(true); + } else { + setDomainAutoLocked(false); + } + }, [jmapServerAutoPickByDomain, hasServerList, jmapServers, formData.username, selectedServerId]); + useEffect(() => { try { if (sessionStorage.getItem('session_expired') === 'true') { @@ -254,7 +295,9 @@ export default function LoginPage() { useEffect(() => { if (!oauthEnabled || !serverUrl) return; - discoverOAuth(oauthIssuerUrl || serverUrl) + setOauthDiscoveryDone(false); + setOauthMetadata(null); + discoverOAuth(effectiveOauthIssuerUrl || serverUrl) .then((metadata) => { setOauthMetadata(metadata); setOauthDiscoveryDone(true); @@ -263,7 +306,7 @@ export default function LoginPage() { setOauthMetadata(null); setOauthDiscoveryDone(true); }); - }, [oauthEnabled, serverUrl, oauthIssuerUrl]); + }, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]); // Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely const ssoError = searchParams.get("sso_error"); @@ -278,7 +321,11 @@ export default function LoginPage() { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ redirect_uri: redirectUri, locale: params.locale }), + body: JSON.stringify({ + redirect_uri: redirectUri, + locale: params.locale, + server_id: selectedServer?.id, + }), }); if (!res.ok) { @@ -304,7 +351,7 @@ export default function LoginPage() { } catch { setOauthLoading(false); } - }, [params.locale]); + }, [params.locale, selectedServer?.id]); useEffect(() => { if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return; @@ -445,7 +492,7 @@ export default function LoginPage() { }; const handleOAuthLogin = async () => { - if (!oauthMetadata || !oauthClientId) return; + if (!oauthMetadata || !effectiveOauthClientId) return; setOauthLoading(true); const verifier = generateCodeVerifier(); @@ -454,9 +501,19 @@ export default function LoginPage() { const prefix = getPathPrefix(params.locale as string); const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; + // Resolve the JMAP URL to send to the callback. Server-list entries win + // over the custom-endpoint input, which wins over the global server URL. + const oauthServerUrl = selectedServer?.url + || (allowCustomJmapEndpoint ? jmapEndpoint : configuredServerUrl); + sessionStorage.setItem("oauth_code_verifier", verifier); sessionStorage.setItem("oauth_state", state); - sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!); + sessionStorage.setItem("oauth_server_url", oauthServerUrl!); + if (selectedServer?.id) { + sessionStorage.setItem("oauth_server_id", selectedServer.id); + } else { + sessionStorage.removeItem("oauth_server_id"); + } if (isAddAccountMode) { sessionStorage.setItem("oauth_add_account_mode", "true"); } @@ -473,7 +530,7 @@ export default function LoginPage() { const authUrl = new URL(oauthMetadata.authorization_endpoint); authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("client_id", oauthClientId); + authUrl.searchParams.set("client_id", effectiveOauthClientId); authUrl.searchParams.set("redirect_uri", redirectUri); authUrl.searchParams.set("scope", OAUTH_SCOPES); authUrl.searchParams.set("state", state); @@ -486,7 +543,10 @@ export default function LoginPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl; + // Server-list entries always win - `allowCustomJmapEndpoint` is only honored + // when the admin hasn't configured a server list. + const effectiveServerUrl = selectedServer?.url + || (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl); const success = await login( effectiveServerUrl, formData.username, @@ -607,13 +667,17 @@ export default function LoginPage() {
{error && (
- -

- {t(`error.${error}`) || t("error.generic")} -

+
+ +
+
+

+ {t(`error.${error}`) || t("error.generic")} +

+
)} @@ -754,37 +818,45 @@ export default function LoginPage() { {/* Session Expired Banner */} {sessionExpired && (
- -

- {t("session_expired")} -

- +
+ +
+
+

+ {t("session_expired")} +

+ +
)} {/* Error Message */} {error && (
- -

- {error === 'invalid_credentials' && showTotpField && totpCode - ? t('error.totp_invalid') - : t(`error.${error}`) || t("error.generic")} -

+
+ +
+
+

+ {error === 'invalid_credentials' && showTotpField && totpCode + ? t('error.totp_invalid') + : t(`error.${error}`) || t("error.generic")} +

+
)} @@ -836,11 +908,15 @@ export default function LoginPage() { )} ) : oauthDiscoveryDone ? ( -
- -

- {t("error.oauth_discovery_failed")} -

+
+
+ +
+
+

+ {t("error.oauth_discovery_failed")} +

+
) : (
@@ -852,8 +928,32 @@ export default function LoginPage() { /* Login Form */
- {/* JMAP Endpoint field (when custom endpoints are allowed) */} - {allowCustomJmapEndpoint && ( + {/* Server picker (when admin has configured a server list) */} + {hasServerList && jmapServers.length > 1 && ( +
+ + + {domainAutoLocked && ( +

+ {t("jmap_server_auto_picked")} +

+ )} +
+ )} + {/* JMAP Endpoint field (only when no server list and custom endpoints are allowed) */} + {!hasServerList && allowCustomJmapEndpoint && (