feat: merge the webmail side of the desktop app

# Conflicts:
#	lib/desktop/types.ts
#	locales/ar/common.json
#	locales/ca/common.json
#	locales/cs/common.json
#	locales/da/common.json
#	locales/de/common.json
#	locales/en/common.json
#	locales/es/common.json
#	locales/et/common.json
#	locales/fa/common.json
#	locales/fr/common.json
#	locales/he/common.json
#	locales/hu/common.json
#	locales/it/common.json
#	locales/ja/common.json
#	locales/ko/common.json
#	locales/lv/common.json
#	locales/mn/common.json
#	locales/nb/common.json
#	locales/nl/common.json
#	locales/pl/common.json
#	locales/pt/common.json
#	locales/ro/common.json
#	locales/ru/common.json
#	locales/sk/common.json
#	locales/th/common.json
#	locales/tr/common.json
#	locales/uk/common.json
#	locales/vi/common.json
#	locales/zh-TW/common.json
#	locales/zh/common.json
#	stores/account-store.ts
This commit is contained in:
Linus Rath
2026-11-03 08:48:10 +01:00
72 changed files with 2099 additions and 42 deletions
+11 -2
View File
@@ -1,6 +1,8 @@
"use client";
import { Suspense, useEffect, useRef, useState } from "react";
import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag";
import { DESKTOP_CLIENT_ID_KEY, DESKTOP_REDIRECT_URI_KEY } from "@/lib/desktop/browser-sign-in";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
@@ -116,9 +118,12 @@ function OAuthCallbackInner() {
const prefix = getPathPrefix(params.locale as string);
// Lite on Stalwart registers one locale-free URI (lib/auth/lite-oauth.ts);
// the entry document brought us here from it.
const redirectUri = LITE_OAUTH_AVAILABLE
// The desktop app's browser sign-in redirected to its loopback listener
// (lib/desktop/browser-sign-in.ts), which this page's origin can't tell.
const desktopRedirectUri = IS_DESKTOP_BUILD ? sessionStorage.getItem(DESKTOP_REDIRECT_URI_KEY) : null;
const redirectUri = desktopRedirectUri ?? (LITE_OAUTH_AVAILABLE
? getLiteOAuthRedirectUri()
: `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
: `${window.location.origin}${prefix}/${params.locale}/auth/callback`);
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri, serverId)
.then((success) => {
@@ -128,6 +133,10 @@ function OAuthCallbackInner() {
sessionStorage.removeItem("oauth_server_url");
sessionStorage.removeItem("oauth_server_id");
sessionStorage.removeItem("oauth_add_account_mode");
if (IS_DESKTOP_BUILD) {
sessionStorage.removeItem(DESKTOP_REDIRECT_URI_KEY);
sessionStorage.removeItem(DESKTOP_CLIENT_ID_KEY);
}
let redirectTo = `${prefix}/${params.locale}`;
try {
const saved = sessionStorage.getItem('redirect_after_login');
+71
View File
@@ -1,6 +1,9 @@
"use client";
import { Suspense, useState, useEffect, useRef, useCallback, useId } from "react";
import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag";
import { forgetDesktopServer, getDesktopBridge } from "@/lib/desktop/bridge";
import { browserSignInAvailable, runDesktopBrowserSignIn } from "@/lib/desktop/browser-sign-in";
import { useRouter } from "@/i18n/navigation";
import { useParams, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -185,6 +188,10 @@ function VersionBadge() {
function LoginPageContent() {
const router = useRouter();
const t = useTranslations("login");
const tDesktop = useTranslations("desktop_sign_in");
// Only the desktop build sets these; a browser never shows them.
const [desktopNotice, setDesktopNotice] = useState<string | null>(null);
const [desktopWaiting, setDesktopWaiting] = useState(false);
const tSessionLimits = useTranslations("session_limits");
const tOffline = useTranslations("offline");
const tTheme = useTranslations("settings.appearance.theme");
@@ -985,6 +992,38 @@ function LoginPageContent() {
window.location.href = authUrl.toString();
};
/** The desktop build: the server's own sign-in page in the system browser. */
// Spelled out, not IS_DESKTOP_BUILD: only this form keeps the route out of other
// builds' bundles, which Lite's check on API strings relies on.
const handleDesktopBrowserSignIn = async () => {
if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1') {
const target = jmapEndpoint.trim();
if (!target) {
setDesktopNotice(tDesktop("server_needed"));
return;
}
setDesktopNotice(null);
setDesktopWaiting(true);
const prefix = getPathPrefix(params.locale as string);
const outcome = await runDesktopBrowserSignIn({
serverUrl: target,
callbackPath: `${prefix}/${params.locale}/auth/callback`,
slot: useAccountStore.getState().getNextCookieSlot(),
remember: persistProviderSession,
addAccount: isAddAccountMode,
});
if (outcome.kind === "callback") {
window.location.href = outcome.callbackUrl;
return;
}
setDesktopWaiting(false);
forgetDesktopServer(target, "failed");
if (outcome.kind === "unavailable") setDesktopNotice(tDesktop("browser_unavailable"));
else if (outcome.kind === "declined") setDesktopNotice(tDesktop("server_declined"));
else if (outcome.kind === "failed") setDesktopNotice(tDesktop("browser_failed"));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -992,9 +1031,21 @@ function LoginPageContent() {
// when the admin hasn't configured a server list.
const effectiveServerUrl = selectedServer?.url
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
// A LAN or plain http server gets a native prompt first, and the app can
// reach it from then on. Without a yes, nothing is sent there.
if (IS_DESKTOP_BUILD) {
setDesktopNotice(null);
const prepared = await getDesktopBridge()?.servers?.prepare(effectiveServerUrl);
if (prepared && !prepared.ok) {
setDesktopNotice(prepared.reason === "invalid" ? tDesktop("server_invalid") : tDesktop("server_declined"));
return;
}
}
if (signInWithToken) {
if (await loginWithToken(effectiveServerUrl, accessToken, persistSession)) {
router.push('/');
} else if (IS_DESKTOP_BUILD) {
forgetDesktopServer(effectiveServerUrl, 'failed');
}
return;
}
@@ -1046,6 +1097,8 @@ function LoginPageContent() {
} else if (isMobileHandoff) {
// Stale payload should never feed into a later retry's redirect.
mobileHandoffPayloadRef.current = null;
} else if (IS_DESKTOP_BUILD) {
forgetDesktopServer(effectiveServerUrl, 'failed');
}
};
@@ -1814,6 +1867,24 @@ function LoginPageContent() {
</>
)}
{process.env.NEXT_PUBLIC_BULWARK_DESKTOP === "1" && !hasServerList && browserSignInAvailable() && (
<Button
type="button"
variant="outline"
className="mt-3 w-full h-11 font-medium text-[0.9375rem] rounded-xl border-border/60 hover:bg-muted/50"
onClick={handleDesktopBrowserSignIn}
disabled={desktopWaiting || isLoading}
data-testid="desktop-browser-sign-in"
>
{desktopWaiting ? <Loader2 className="w-4 h-4 animate-spin me-2" /> : <LogIn className="w-4 h-4 me-2" />}
{desktopWaiting ? tDesktop("browser_waiting") : tDesktop("browser_button")}
</Button>
)}
{IS_DESKTOP_BUILD && desktopNotice && (
<p role="alert" data-testid="desktop-notice" className="mt-2 text-sm text-warning leading-relaxed">
{desktopNotice}
</p>
)}
{((serverOauthEnabled && oauthDiscoveryDone && !oauthMetadata) || liteOAuthFailed) && (
<div className="mt-2 p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
+86
View File
@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { DEFAULT_OAUTH_SCOPES, withProviderDataScopes } from '@/lib/oauth/scopes';
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
import { resolveCustomUpstream } from '@/lib/oauth/custom-upstream';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
/** The client id on a server that won't register one: 0.16 accepts any id. */
const FALLBACK_CLIENT_ID = 'bulwark-desktop';
const REGISTERED_REDIRECT = 'http://127.0.0.1/bulwark-desktop/callback';
const REGISTER_TIMEOUT_MS = 5000;
function isLoopbackRedirect(value: unknown): boolean {
if (typeof value !== 'string') return false;
try {
const url = new URL(value);
return url.protocol === 'http:' && url.hostname === '127.0.0.1' && url.pathname === '/bulwark-desktop/callback';
} catch {
return false;
}
}
/**
* Registers the desktop app as a public client. Stalwart 1.0 needs this: its
* ids are stateless, so the same metadata gives the same id and nothing is
* stored. It accepts a loopback redirect and ignores the port when matching.
*/
async function registerClient(base: string, scope: string): Promise<string> {
try {
const response = await fetch(`${base}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'Bulwark',
redirect_uris: [REGISTERED_REDIRECT],
token_endpoint_auth_method: 'none',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
scope,
}),
redirect: 'error',
signal: AbortSignal.timeout(REGISTER_TIMEOUT_MS),
});
if (!response.ok) return FALLBACK_CLIENT_ID;
const body = (await response.json()) as { client_id?: unknown };
return typeof body.client_id === 'string' && body.client_id ? body.client_id : FALLBACK_CLIENT_ID;
} catch (error) {
logger.warn('Desktop OAuth: client registration failed', { error: error instanceof Error ? error.message : String(error) });
return FALLBACK_CLIENT_ID;
}
}
/**
* What the desktop app needs to send the user to a server's own sign-in page:
* the authorize endpoint and a client id. Only in the desktop build. Discovery
* runs here rather than in the page, for the same reason as on the login page:
* the documents often have no CORS headers.
*/
export async function POST(request: NextRequest) {
if (!IS_DESKTOP_BUILD) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
try {
const { serverUrl, redirectUri } = await request.json();
if (!isLoopbackRedirect(redirectUri)) {
return NextResponse.json({ error: 'invalid_redirect' }, { status: 400 });
}
const upstream = await resolveCustomUpstream(serverUrl, FALLBACK_CLIENT_ID);
if (!upstream) return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
const metadata = await discoverOAuth(upstream.serverUrl, { validateEndpoint: getDiscoveryValidator(upstream.serverUrl) });
if (!metadata) return NextResponse.json({ error: 'no_oauth' }, { status: 404 });
const scope = withProviderDataScopes(DEFAULT_OAUTH_SCOPES, metadata.scopes_supported);
return NextResponse.json({
authorization_endpoint: metadata.authorization_endpoint,
client_id: await registerClient(upstream.serverUrl, scope),
scope,
});
} catch (error) {
logger.error('Desktop OAuth discovery failed', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'discovery_failed' }, { status: 503 });
}
}
+1 -1
View File
@@ -184,7 +184,7 @@ export async function POST(request: NextRequest) {
// password. `trusted` only relaxes the public-address requirement.
// Wrong credentials tried from here count against this server's address
// upstream, so each client gets a failure budget (lib/auth/verify-budget.ts).
const clientIp = loginClientAddress(request);
const clientIp = loginClientAddress(request, upstreamUrl);
const refund = await reserveLoginAttempt(clientIp);
if (!refund) {
return NextResponse.json(
+1 -1
View File
@@ -80,7 +80,7 @@ export async function POST(request: NextRequest) {
// private network) - never the credential check.
// Wrong credentials tried from here count against this server's address
// upstream, so each client gets a failure budget (lib/auth/verify-budget.ts).
const clientIp = loginClientAddress(request);
const clientIp = loginClientAddress(request, upstreamUrl);
const refund = await reserveLoginAttempt(clientIp);
if (!refund) {
return NextResponse.json(
+35 -11
View File
@@ -41,6 +41,14 @@ import {
writeSignInStamp,
} from '@/lib/auth/session-age';
import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import {
clearCustomUpstream,
readCustomUpstream,
resolveCustomUpstream,
writeCustomUpstream,
type CustomUpstream,
} from '@/lib/oauth/custom-upstream';
function parseSlot(raw: string | null): number | null {
if (raw === null) return null;
@@ -57,13 +65,13 @@ function getSlot(request: NextRequest): number {
// not hold it up for long.
const REVOCATION_TIMEOUT_MS = 3000;
async function revokeRefreshToken(token: string, serverId: string | null, metadata: OAuthMetadata | null): Promise<void> {
async function revokeRefreshToken(token: string, serverId: string | null, metadata: OAuthMetadata | null, upstream: CustomUpstream | null): Promise<void> {
const revocationEndpoint = metadata?.revocation_endpoint;
if (!revocationEndpoint) return;
const params = buildOAuthParams({
token,
token_type_hint: 'refresh_token',
}, serverId, await defaultClientOptions(serverId).catch(() => ({ fallbackClientId: DEFAULT_CLIENT_ID })));
}, serverId, await defaultClientOptions(serverId, upstream).catch(() => ({ fallbackClientId: DEFAULT_CLIENT_ID })));
try {
// Never follow a redirect with the refresh token and client secret in
@@ -90,12 +98,12 @@ async function revokeRefreshToken(token: string, serverId: string | null, metada
* Stalwart, and for a logout page shared with other apps, which must keep
* working once the provider starts advertising its own.
*/
function endSessionUrlFor(metadata: OAuthMetadata | null, serverId: string | null, idToken: string | undefined): string | null {
function endSessionUrlFor(metadata: OAuthMetadata | null, serverId: string | null, idToken: string | undefined, upstream: CustomUpstream | null): string | null {
if (!isEndSessionEnabled()) return null;
let clientId: string;
let configuredEndpoint: string | undefined;
try {
({ clientId, endSessionUrl: configuredEndpoint } = getRequiredConfig(serverId));
({ clientId, endSessionUrl: configuredEndpoint } = upstream ? getRequiredConfig(serverId, { upstream }) : getRequiredConfig(serverId));
} catch {
return null;
}
@@ -146,7 +154,7 @@ export async function POST(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
try {
const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId, remember: bodyRemember } = await request.json();
const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId, remember: bodyRemember, custom_server_url: customServerUrl, client_id: bodyClientId } = await request.json();
// "Remember me" left unticked: the cookies end with the browser session.
// Clients from before the choice remembered every sign-in.
const remember = bodyRemember !== false;
@@ -158,7 +166,14 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
// The desktop app signs in to a server the user typed, with a client it
// registered there. Nothing configured here knows about either.
const customUpstream = IS_DESKTOP_BUILD && !serverId ? await resolveCustomUpstream(customServerUrl, bodyClientId) : null;
if (IS_DESKTOP_BUILD && !serverId && !customUpstream) {
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
}
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId, customUpstream ? { upstream: customUpstream } : undefined);
// A sign-in: the maximum session length counts from here, on the server
// and, from this answer, in the browser.
const signedInAt = Date.now();
@@ -187,6 +202,7 @@ export async function POST(request: NextRequest) {
} else if (cookieStore.get(serverCookieName)) {
cookieStore.delete(serverCookieName);
}
if (customUpstream) writeCustomUpstream(cookieStore, slot, customUpstream, remember);
return response;
} catch (error) {
@@ -257,12 +273,13 @@ export async function PUT(request: NextRequest) {
// The refresh token may have been minted by the password+TOTP login route,
// which works without a configured OAuth client by falling back to the
// default client id - refreshing must fall back the same way (#873).
const tokenEndpoint = await getTokenEndpoint(serverId, { fallbackClientId: DEFAULT_CLIENT_ID });
const upstream = IS_DESKTOP_BUILD ? await readCustomUpstream(cookieStore, slot) : null;
const tokenEndpoint = await getTokenEndpoint(serverId, upstream ? { upstream } : { fallbackClientId: DEFAULT_CLIENT_ID });
const params = buildOAuthParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
}, serverId, await defaultClientOptions(serverId));
}, serverId, await defaultClientOptions(serverId, upstream));
const tokenResponse = await observeUpstream('oauth', () => fetch(tokenEndpoint, {
method: 'POST',
@@ -283,6 +300,7 @@ export async function PUT(request: NextRequest) {
cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
cookieStore.delete(accessTokenCookieName(slot));
clearCustomUpstream(cookieStore, slot);
clearIdToken(cookieStore, slot, request.nextUrl.basePath);
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
}
@@ -324,6 +342,10 @@ export async function PUT(request: NextRequest) {
});
} catch (error) {
logger.error('Token refresh error', { error: error instanceof Error ? error.message : 'Unknown error' });
// On the desktop this route runs on the user's machine, so an unreachable
// mail server lands here. The client signs out after repeated 500s; a 503
// tells it to keep the session and try again.
if (IS_DESKTOP_BUILD) return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
@@ -343,19 +365,20 @@ async function signOutSlot(
const refreshToken = cookieStore.get(cookieName)?.value;
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
const idToken = cookieStore.get(idTokenCookieName(slot))?.value;
const upstream = IS_DESKTOP_BUILD ? await readCustomUpstream(cookieStore, slot) : null;
// With revocation off (#1388) the token is only dropped from the browser.
const tokenToRevoke = refreshToken && isRevokeOnSignOutEnabled() ? refreshToken : undefined;
let endSessionUrl: string | null = null;
if (tokenToRevoke || endSession) {
const metadata = await getMetadata(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }).catch((err) => {
const metadata = await getMetadata(serverId, upstream ? { upstream } : { fallbackClientId: DEFAULT_CLIENT_ID }).catch((err) => {
logger.warn('Failed to discover OAuth metadata during logout', {
error: err instanceof Error ? err.message : 'Unknown error',
});
return null;
});
if (tokenToRevoke) await revokeRefreshToken(tokenToRevoke, serverId, metadata);
if (endSession) endSessionUrl = endSessionUrlFor(metadata, serverId, idToken);
if (tokenToRevoke) await revokeRefreshToken(tokenToRevoke, serverId, metadata, upstream);
if (endSession) endSessionUrl = endSessionUrlFor(metadata, serverId, idToken, upstream);
}
// Cleared whether or not revocation worked: the browser must not be able
@@ -366,6 +389,7 @@ async function signOutSlot(
if (cookieStore.get(name)) cookieStore.delete(name);
}
clearSignInStamp(cookieStore, slot);
if (IS_DESKTOP_BUILD) clearCustomUpstream(cookieStore, slot);
if (idToken !== undefined) clearIdToken(cookieStore, slot, basePath);
return endSessionUrl;
}
+16 -2
View File
@@ -16,6 +16,10 @@ import { loginClientAddress } from '@/lib/auth/client-address';
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
import { writeSignInStamp } from '@/lib/auth/session-age';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { isDesktopTrustedServer } from '@/lib/desktop/trusted-servers';
import { stalwartDefaultClientId } from '@/lib/auth/stalwart-password-login';
import { writeCustomUpstream } from '@/lib/oauth/custom-upstream';
/**
* Exchange a password + (optional) TOTP code for OAuth tokens.
@@ -113,7 +117,12 @@ async function attemptLogin(
logger.info('TOTP login succeeded');
void recordLogin(username, base);
return await storeAndRespond(result.tokens, slot, serverId, rememberMe);
// The desktop app's servers are in no configured list, so the refresh has to
// be told where this token belongs and which client it was issued to.
const upstream = IS_DESKTOP_BUILD && !serverId
? { serverUrl: base, clientId: await stalwartDefaultClientId(base, clientId, upstreamTrusted) }
: null;
return await storeAndRespond(result.tokens, slot, serverId, rememberMe, upstream);
}
function trimUrl(url: string): string {
@@ -164,6 +173,9 @@ export async function POST(request: NextRequest) {
resolvedServerId = matchedEntry.id;
} else if (configuredServerUrl && !customRequested) {
upstreamUrl = configuredServerUrl;
// Spelled out as in lib/admin/jmap-servers.ts, so other builds don't hold it.
} else if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && isDesktopTrustedServer(serverUrl)) {
upstreamUrl = serverUrl;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
logger.warn('TOTP login: rejected non-public server URL');
@@ -193,7 +205,7 @@ export async function POST(request: NextRequest) {
slot,
resolvedServerId,
upstreamTrusted,
loginClientAddress(request),
loginClientAddress(request, upstreamUrl),
// Clients from before the flag remembered every sign-in.
rememberMe !== false,
);
@@ -210,6 +222,7 @@ async function storeAndRespond(
slot: number,
serverId: string | null,
rememberMe: boolean,
upstream: { serverUrl: string; clientId: string } | null,
): Promise<NextResponse> {
const cookieStore = await cookies();
// A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
@@ -227,6 +240,7 @@ async function storeAndRespond(
} else {
cookieStore.delete(serverCookieName);
}
if (upstream && tokens.refresh_token) writeCustomUpstream(cookieStore, slot, upstream, rememberMe);
return NextResponse.json({
access_token: tokens.access_token,
+2 -2
View File
@@ -5,7 +5,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getClientIP } from '@/lib/admin/session';
import { loginClientAddress } from '@/lib/auth/client-address';
import { reserveVerifyProbe, type BudgetRefund } from '@/lib/auth/verify-budget';
import { isStoreUnavailable } from '@/lib/auth/store-unavailable';
@@ -83,7 +83,7 @@ export async function POST(request: NextRequest) {
// Wrong passwords tried from here count against this server's address
// upstream; over budget the browser probes the JMAP server itself.
const clientIp = getClientIP(request);
const clientIp = loginClientAddress(request, upstreamUrl);
let refund: BudgetRefund | null;
try {
refund = await reserveVerifyProbe(clientIp);
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useMemo, useCallback, useId, useRef } from 'react';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import QRCode from 'qrcode';
@@ -1569,8 +1570,12 @@ export function AccountSecuritySettings() {
<SettingsSection id="devices" title={t('title')} description={t('description')}>
{!IS_LITE ? (
<div className="space-y-6">
<LinkDeviceSection />
<div className="border-t border-border" />
{!IS_DESKTOP_BUILD && (
<>
<LinkDeviceSection />
<div className="border-t border-border" />
</>
)}
<div className="text-sm text-muted-foreground" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t.raw('not_available')) }} />
</div>
) : (
@@ -1610,7 +1615,7 @@ export function AccountSecuritySettings() {
description={withoutPassword ? t('description') : t('sections.devices_description')}
>
<div className="space-y-6">
{!IS_LITE && (
{!IS_LITE && !IS_DESKTOP_BUILD && (
<>
<LinkDeviceSection />
<div className="border-t border-border" />
+47
View File
@@ -31,6 +31,53 @@ afterEach(() => {
while (temps.length) rmSync(temps.pop()!, { recursive: true, force: true });
});
describe('the desktop trust path', () => {
it('fails a server build that still holds it', () => {
const dir = tempDir();
put(dir, 'server.js');
put(dir, '.next/server/chunks/123.js', 'holder[Symbol.for("bulwark.desktop.trustedJmapOrigins")]');
expect(guardStandalone(dir, { desktopBuild: false })).toEqual(['.next/server/chunks/123.js (desktop trust path in a server build)']);
});
it('fails a server build that still reads the flag at runtime', () => {
const dir = tempDir();
put(dir, 'server.js');
put(dir, '.next/server/chunks/9.js', 'let a="1"===process.env.NEXT_PUBLIC_BULWARK_DESKTOP');
expect(guardStandalone(dir, { desktopBuild: false })).toHaveLength(1);
});
it('passes a server build whose server.js holds the config, with the name as a JSON key', () => {
const dir = tempDir();
put(dir, 'server.js', 'const nextConfig = {"env":{"NEXT_PUBLIC_BULWARK_LITE":"","NEXT_PUBLIC_BULWARK_DESKTOP":""}}');
expect(guardStandalone(dir, { desktopBuild: false })).toEqual([]);
});
it('fails the bracket form of the read too', () => {
const dir = tempDir();
put(dir, 'server.js');
put(dir, '.next/server/chunks/9.js', 'let a=process.env["NEXT_PUBLIC_BULWARK_DESKTOP"]');
expect(guardStandalone(dir, { desktopBuild: false })).toHaveLength(1);
});
it('passes the desktop build itself', () => {
const dir = tempDir();
put(dir, 'server.js');
put(dir, '.next/server/chunks/123.js', 'holder[Symbol.for("bulwark.desktop.trustedJmapOrigins")]');
expect(guardStandalone(dir, { desktopBuild: true })).toEqual([]);
});
it('fails the command line check on a server build', () => {
const dir = tempDir();
put(dir, 'server.js');
put(dir, '.next/server/chunks/123.js', 'bulwark.desktop.trustedJmapOrigins');
const env = { ...process.env };
delete env.NEXT_PUBLIC_BULWARK_DESKTOP;
const run = spawnSync(process.execPath, [SCRIPT, dir], { encoding: 'utf8', env });
expect(run.status).toBe(1);
expect(run.stderr).toContain('desktop trust path');
});
});
describe('standalone guard check', () => {
it('fails the build on a planted signing key', () => {
const dir = tempDir();
+5
View File
@@ -1,3 +1,5 @@
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
/**
* Utilities for multi-account support:
* - Account ID generation
@@ -136,6 +138,9 @@ export function isHttp2Available(): boolean {
* once HTTP/2+ is observed, otherwise returns {@link MAX_ACCOUNTS_HTTP1}.
*/
export function getMaxAccounts(): number {
// The cap is for the push streams sharing one http/1.1 origin. The embedded
// server speaks http/1.1, but each account streams from its own mail server.
if (IS_DESKTOP_BUILD) return MAX_ACCOUNT_SLOTS;
return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1;
}
+6
View File
@@ -1,6 +1,8 @@
/**
* Multi-server JMAP support: schema, parsing, lookup, and redaction helpers.
*/
import { isDesktopTrustedServer } from '@/lib/desktop/trusted-servers';
export interface JmapServerOAuthConfig {
clientId?: string;
@@ -223,6 +225,10 @@ export function resolveTrustedJmapUrl(
}
const matched = servers.find((s) => normalizeUrl(s.url) === target);
if (matched) return matched.url;
// A server the user approved in the desktop app. Spelled out, not IS_DESKTOP_BUILD:
// only this form lets the bundler drop the branch and the module behind it, so
// that no other build holds this path at all (scripts/standalone-guard.mjs checks).
if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && isDesktopTrustedServer(requestedUrl)) return trimUrl(requestedUrl);
// No match - caller decides whether to honor the request anyway (e.g. when
// allowCustomJmapEndpoint is enabled).
return null;
+8 -2
View File
@@ -1,5 +1,7 @@
import { BlockList, isIP } from 'node:net';
import { getClientIP } from '@/lib/admin/session';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { desktopLoginBudgetKey } from '@/lib/desktop/login-budget';
/** Addresses that name no client: loopback and unspecified. */
const NOT_A_CLIENT = new BlockList();
@@ -39,7 +41,11 @@ export function forwardedClientHeaders(clientIp: string | undefined): Record<str
return { 'X-Forwarded-For': clientIp };
}
/** The client address of `request` as the sign-in routes forward and budget it. */
export function loginClientAddress(request: Request): string {
/**
* The client address of `request` as the sign-in routes forward and budget it.
* The desktop build has one client, so there it counts per mail server instead.
*/
export function loginClientAddress(request: Request, serverUrl?: unknown): string {
if (IS_DESKTOP_BUILD) return desktopLoginBudgetKey(serverUrl);
return getClientIP(request);
}
+4 -1
View File
@@ -121,7 +121,9 @@ export type PayloadPurpose =
/** A pairing grant or code record in the shared store (lib/auth/pairing-store.ts). */
| 'pairing-store'
/** A parked mobile hand-off sign-in in the shared store (lib/auth/handoff-store.ts). */
| 'handoff-store';
| 'handoff-store'
/** Which server a desktop account's refresh token belongs to (lib/oauth/custom-upstream.ts). */
| 'custom-upstream';
/**
* Whether a blob minted before purposes were bound may be re-admitted for
@@ -145,6 +147,7 @@ const LEGACY_PAYLOAD_MARKERS: Record<PayloadPurpose, (payload: Record<string, un
'session-age': () => false,
'pairing-store': () => false,
'handoff-store': () => false,
'custom-upstream': () => false,
};
export function encryptPayload(payload: Record<string, unknown>, purpose: PayloadPurpose): string {
+24
View File
@@ -10,19 +10,43 @@
* waits at most `timeoutMs` for all of them together.
*/
import { getDesktopBridge } from '@/lib/desktop/bridge';
export type ComposeFlush = () => Promise<void>;
/** Less than the desktop shell waits for a closing window. */
const DESKTOP_FLUSH_MS = 3000;
let nextId = 1;
const flushes = new Map<number, ComposeFlush>();
const waiters = new Set<(flush: ComposeFlush) => void>();
let stopDesktopFlush: (() => void) | null = null;
/**
* In the desktop app, closing a window or quitting saves the open drafts first. The
* shell only waits while a composer is here, so a window without one closes at once.
*/
function syncDesktopFlush(): void {
if (flushes.size === 0) {
stopDesktopFlush?.();
stopDesktopFlush = null;
return;
}
if (stopDesktopFlush) return;
const lifecycle = getDesktopBridge()?.lifecycle;
if (lifecycle) stopDesktopFlush = lifecycle.onFlushRequest(() => flushComposers(DESKTOP_FLUSH_MS));
}
/** Register a composer's flush; returns the unregister function. */
export function registerComposeFlush(flush: ComposeFlush): () => void {
const id = nextId++;
flushes.set(id, flush);
for (const waiter of [...waiters]) waiter(flush);
syncDesktopFlush();
return () => {
flushes.delete(id);
syncDesktopFlush();
};
}
+233
View File
@@ -0,0 +1,233 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BulwarkDesktopBridge } from '../types';
/** The webmail's view of the bridge, and the http server route. */
// The client's first import compiles a good part of the app.
vi.setConfig({ testTimeout: 60_000 });
function setBridge(bridge: Partial<BulwarkDesktopBridge> | undefined) {
window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined;
}
afterEach(() => {
setBridge(undefined);
vi.unstubAllEnvs();
vi.unstubAllGlobals();
sessionStorage.clear();
});
describe('feature detection', () => {
it('finds nothing in a browser', async () => {
const { getDesktopBridge, isDesktopApp } = await import('../bridge');
expect(getDesktopBridge()).toBeNull();
expect(isDesktopApp()).toBe(false);
});
it('finds the bridge in the app', async () => {
const { isDesktopApp } = await import('../bridge');
setBridge({ version: 1, platform: 'win32' });
expect(isDesktopApp()).toBe(true);
});
it('treats a newer bridge major as absent rather than guessing at it', async () => {
const { getDesktopBridge } = await import('../bridge');
setBridge({ version: 2, platform: 'win32' });
expect(getDesktopBridge()).toBeNull();
});
});
describe('web push', () => {
beforeEach(() => {
Object.defineProperty(navigator, 'serviceWorker', { value: {}, configurable: true });
vi.stubGlobal('PushManager', function PushManager() {});
vi.stubGlobal('Notification', function Notification() {});
});
it('is on offer in a browser', async () => {
const { isWebPushSupported } = await import('@/lib/web-push');
expect(isWebPushSupported()).toBe(true);
});
it('is off in the app: Electron has the API, but subscribing fails', async () => {
const { isWebPushSupported } = await import('@/lib/web-push');
setBridge({ version: 1, platform: 'win32' });
expect(isWebPushSupported()).toBe(false);
});
});
describe('desktopJmapBase', () => {
async function load() {
vi.resetModules();
return import('../jmap-route');
}
it('leaves every URL alone outside the desktop build', async () => {
const { desktopJmapBase, desktopJmapOrigin } = await load();
expect(desktopJmapBase('http://192.168.1.5:8080')).toBe('http://192.168.1.5:8080');
expect(desktopJmapOrigin('http://192.168.1.5:8080')).toBe('http://192.168.1.5:8080');
});
it('moves a plain http server onto the same-origin route', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const { desktopJmapBase, desktopJmapOriginId } = await load();
const id = desktopJmapOriginId('http://192.168.1.5:8080');
expect(id).toMatch(/^[A-Za-z0-9_-]+$/);
expect(Buffer.from(id, 'base64url').toString()).toBe('http://192.168.1.5:8080');
expect(desktopJmapBase('http://192.168.1.5:8080')).toBe(`${window.location.origin}/__jmap/${id}`);
});
it('keeps an https server direct', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const { desktopJmapBase, desktopJmapOrigin } = await load();
expect(desktopJmapBase('https://mail.lan')).toBe('https://mail.lan');
expect(desktopJmapOrigin('https://mail.lan')).toBe('https://mail.lan');
});
});
describe('JMAPClient on an approved http server', () => {
const SESSION = {
capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500 }, 'urn:ietf:params:jmap:mail': {} },
accounts: { a1: { name: 'ent-alice', isPersonal: true, isReadOnly: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } } },
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a1' },
username: 'ent-alice',
apiUrl: 'http://stalwart.local:8080/jmap/',
downloadUrl: 'http://stalwart.local:8080/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
uploadUrl: 'http://stalwart.local:8080/jmap/upload/{accountId}/',
eventSourceUrl: 'http://stalwart.local:8080/jmap/eventsource/',
state: 's1',
};
function stubFetch() {
const urls: string[] = [];
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
urls.push(url);
if (url.includes('/.well-known/jmap')) return new Response(JSON.stringify(SESSION), { status: 200 });
return new Response(JSON.stringify({ methodResponses: [], sessionState: 's1' }), { status: 200 });
}));
return urls;
}
async function connect(serverUrl: string) {
vi.resetModules();
const { JMAPClient } = await import('@/lib/jmap/client');
const client = new JMAPClient(serverUrl, 'ent-alice', 'pw');
await client.connect();
const session = (client as unknown as { session: typeof SESSION }).session;
client.disconnect();
return session;
}
it('sends discovery and every session URL through /__jmap in the desktop build', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const urls = stubFetch();
const noteSession = vi.fn();
setBridge({ version: 1, platform: 'win32', servers: { prepare: vi.fn(), noteSession } });
const session = await connect('http://192.168.1.5:8080');
const base = `${window.location.origin}/__jmap/${Buffer.from('http://192.168.1.5:8080').toString('base64url')}`;
expect(urls[0]).toBe(`${base}/.well-known/jmap`);
expect(session.apiUrl).toBe(`${base}/jmap/`);
expect(session.downloadUrl).toBe(`${base}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`);
expect(session.uploadUrl).toBe(`${base}/jmap/upload/{accountId}/`);
expect(session.eventSourceUrl).toBe(`${base}/jmap/eventsource/`);
expect(urls.filter((u) => u.startsWith('http://192.168.1.5'))).toEqual([]);
expect(noteSession).toHaveBeenCalledWith('http://192.168.1.5:8080', expect.objectContaining({ apiUrl: `${base}/jmap/` }));
});
it('a server build talks to the server directly, as it always did', async () => {
const urls = stubFetch();
const session = await connect('http://192.168.1.5:8080');
expect(urls[0]).toBe('http://192.168.1.5:8080/.well-known/jmap');
expect(session.apiUrl).toBe('http://192.168.1.5:8080/jmap/');
});
});
describe('runDesktopBrowserSignIn', () => {
const ENDPOINTS = { authorization_endpoint: 'https://mail.example/authorize', client_id: 'registered-client', scope: 'openid email' };
function authBridge(result: unknown) {
const completeBrowserSignIn = vi.fn(async (_flowId: string, url: string) => (
typeof result === 'function' ? (result as (u: string) => unknown)(url) : result
));
const bridge = {
version: 1,
platform: 'win32',
servers: { prepare: vi.fn(async () => ({ ok: true, approved: false })), noteSession: vi.fn() },
auth: {
beginBrowserSignIn: vi.fn(async () => ({ flowId: 'f1', redirectUri: 'http://127.0.0.1:51234/bulwark-desktop/callback' })),
completeBrowserSignIn,
cancelBrowserSignIn: vi.fn(),
},
} as unknown as BulwarkDesktopBridge;
setBridge(bridge);
return bridge;
}
const options = { serverUrl: 'https://mail.example', callbackPath: '/en/auth/callback', slot: 1, remember: true, addAccount: false };
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(ENDPOINTS), { status: 200 })));
});
it('runs PKCE with the loopback redirect and parks what the callback page needs', async () => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
let authorize = new URL('https://x');
authBridge((url: string) => {
authorize = new URL(url);
return { ok: true, code: 'the-code', state: authorize.searchParams.get('state') };
});
const outcome = await runDesktopBrowserSignIn(options);
expect(outcome.kind).toBe('callback');
expect(authorize.origin + authorize.pathname).toBe('https://mail.example/authorize');
expect(authorize.searchParams.get('client_id')).toBe('registered-client');
expect(authorize.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:51234/bulwark-desktop/callback');
expect(authorize.searchParams.get('code_challenge_method')).toBe('S256');
expect(authorize.searchParams.get('code_challenge')).toBeTruthy();
expect(sessionStorage.getItem('oauth_redirect_uri')).toBe('http://127.0.0.1:51234/bulwark-desktop/callback');
expect(sessionStorage.getItem('oauth_client_id')).toBe('registered-client');
expect(sessionStorage.getItem('oauth_server_url')).toBe('https://mail.example');
expect(sessionStorage.getItem('oauth_cookie_slot')).toBe('1');
expect(sessionStorage.getItem('oauth_code_verifier')).toBeTruthy();
});
it('rejects an answer with another state', async () => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
authBridge({ ok: true, code: 'c', state: 'forged' });
const outcome = await runDesktopBrowserSignIn(options);
expect(outcome).toEqual({ kind: 'failed', error: 'state_mismatch' });
expect(sessionStorage.getItem('oauth_code_verifier')).toBeNull();
});
it('sends nothing to a server the user declined', async () => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
const bridge = authBridge({ ok: false, error: 'cancelled' });
bridge.servers!.prepare = vi.fn(async () => ({ ok: false as const, reason: 'declined' as const }));
const outcome = await runDesktopBrowserSignIn(options);
expect(outcome.kind).toBe('declined');
expect(bridge.auth!.beginBrowserSignIn).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it('says so when the server has no OAuth, and closes the listener', async () => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 })));
const bridge = authBridge({ ok: true });
const outcome = await runDesktopBrowserSignIn(options);
expect(outcome.kind).toBe('unavailable');
expect(bridge.auth!.cancelBrowserSignIn).toHaveBeenCalledWith('f1');
expect(bridge.auth!.completeBrowserSignIn).not.toHaveBeenCalled();
});
});
describe('isDesktopBlockedRoute', () => {
it('closes pairing and hand-off, and nothing else', async () => {
const { isDesktopBlockedRoute } = await import('../blocked-routes');
for (const path of ['/api/auth/pair/create', '/api/auth/pair/token', '/api/auth/handoff/redeem', '/api/auth/impersonate']) {
expect(isDesktopBlockedRoute(path), path).toBe(true);
}
for (const path of ['/api/auth/session', '/api/auth/token', '/api/auth/pairs', '/en/login']) {
expect(isDesktopBlockedRoute(path), path).toBe(false);
}
});
});
@@ -0,0 +1,231 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* A desktop account's refresh token belongs to a server only the app knows. A
* TOTP or browser sign-in has to be able to refresh there, never at a configured
* server, and never with a configured client secret.
*/
vi.mock('next/server', () => ({
NextResponse: {
json: (data: unknown, init?: { status?: number }) => ({
json: async () => data,
status: init?.status ?? 200,
}),
},
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
}));
vi.mock('@/lib/oauth/cookie-config', () => ({
getCookieOptions: () => ({ path: '/', maxAge: 2592000 }),
getSignInCookieOptions: (remember: boolean) => ({ path: '/', ...(remember ? { maxAge: 2592000 } : {}) }),
}));
const config: Record<string, unknown> = {};
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback),
},
}));
vi.mock('@/lib/auth/crypto', () => ({
encryptPayload: (payload: Record<string, unknown>) => JSON.stringify(payload),
decryptPayload: (value: string) => JSON.parse(value),
}));
const isPublicHttpUrl = vi.hoisted(() => vi.fn(async () => true));
vi.mock('@/lib/security/url-guard', async (original) => ({
...(await original<object>()),
isPublicHttpUrl,
}));
vi.mock('@/lib/stalwart/auth-context', () => ({
clearStalwartAuthContextInStore: vi.fn(),
readStalwartAuthContextFromStore: () => null,
}));
vi.setConfig({ testTimeout: 60_000 });
class FakeCookies {
store = new Map<string, string>();
get(name: string) {
const value = this.store.get(name);
return value === undefined ? undefined : { name, value };
}
set(name: string, value: string) {
this.store.set(name, value);
}
delete(arg: string | { name: string }) {
this.store.delete(typeof arg === 'string' ? arg : arg.name);
}
}
let cookieStore: FakeCookies;
vi.mock('next/headers', () => ({ cookies: async () => cookieStore }));
const SERVER = 'https://mail.typed.example';
const CONFIGURED = 'https://configured.example';
const metadata = (base: string) => ({
issuer: base,
authorization_endpoint: `${base}/authorize`,
token_endpoint: `${base}/auth/token`,
revocation_endpoint: `${base}/auth/revoke`,
});
function stubUpstream(impl?: (url: string, body: string) => Response) {
const calls: { url: string; body: string }[] = [];
vi.stubGlobal('fetch', vi.fn(async (input: string | URL, init?: RequestInit) => {
const url = String(input);
const base = new URL(url).origin;
if (url.includes('/.well-known/')) return new Response(JSON.stringify(metadata(base)), { status: 200 });
const body = String(init?.body ?? '');
calls.push({ url, body });
return impl ? impl(url, body) : new Response(JSON.stringify({ access_token: 'new-access', expires_in: 3600 }), { status: 200 });
}));
return calls;
}
function request(method: string, params: Record<string, string> = {}, body?: unknown): unknown {
return {
method,
headers: new Headers({ 'sec-fetch-site': 'same-origin' }),
nextUrl: { searchParams: new URLSearchParams(params), basePath: '' },
json: async () => body,
};
}
async function loadRoute() {
vi.resetModules();
return import('@/app/api/auth/token/route');
}
beforeEach(() => {
cookieStore = new FakeCookies();
for (const key of Object.keys(config)) delete config[key];
isPublicHttpUrl.mockReset();
isPublicHttpUrl.mockImplementation(async () => true);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe('PUT /api/auth/token in the desktop build', () => {
beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
cookieStore.set('jmap_rt', 'refresh-1');
cookieStore.set('jmap_rtu', JSON.stringify({ s: SERVER, c: 'client-from-register' }));
});
it('refreshes at the server of the record, with its client', async () => {
const calls = stubUpstream();
const { PUT } = await loadRoute();
const res = (await PUT(request('PUT', { force: 'true' }) as Parameters<typeof PUT>[0])) as unknown as { status: number };
expect(res.status).toBe(200);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe(`${SERVER}/auth/token`);
const sent = new URLSearchParams(calls[0].body);
expect(sent.get('client_id')).toBe('client-from-register');
expect(sent.get('refresh_token')).toBe('refresh-1');
});
it('goes to the record even with a configured server, and never sends its secret', async () => {
config.jmapServerUrl = CONFIGURED;
config.oauthClientId = 'configured-client';
config.oauthClientSecret = 'configured-secret';
const calls = stubUpstream();
const { PUT } = await loadRoute();
await PUT(request('PUT', { force: 'true' }) as Parameters<typeof PUT>[0]);
expect(calls[0].url).toBe(`${SERVER}/auth/token`);
const sent = new URLSearchParams(calls[0].body);
expect(sent.get('client_secret')).toBeNull();
expect(sent.get('client_id')).toBe('client-from-register');
});
it('answers 503, not 500, when the mail server is unreachable, so the session stays', async () => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('fetch failed'); }));
const { PUT } = await loadRoute();
const res = (await PUT(request('PUT', { force: 'true' }) as Parameters<typeof PUT>[0])) as unknown as { status: number };
expect(res.status).toBe(503);
expect(cookieStore.get('jmap_rt')).toBeDefined();
});
it('refuses a record whose address no longer passes the guard', async () => {
isPublicHttpUrl.mockImplementation(async () => false);
const calls = stubUpstream();
const { PUT } = await loadRoute();
const res = (await PUT(request('PUT', { force: 'true' }) as Parameters<typeof PUT>[0])) as unknown as { status: number };
expect(calls).toHaveLength(0);
expect(res.status).toBe(503);
});
});
describe('POST /api/auth/token in the desktop build', () => {
const body = {
code: 'code-1',
code_verifier: 'verifier',
redirect_uri: 'http://127.0.0.1:51234/bulwark-desktop/callback',
slot: 2,
custom_server_url: SERVER,
client_id: 'client-from-register',
};
beforeEach(() => vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'));
it('exchanges at the typed server and leaves a record for the refresh', async () => {
const calls = stubUpstream(() => new Response(JSON.stringify({ access_token: 'a', refresh_token: 'r', expires_in: 3600 }), { status: 200 }));
const { POST } = await loadRoute();
const res = (await POST(request('POST', {}, body) as Parameters<typeof POST>[0])) as unknown as { status: number };
expect(res.status).toBe(200);
expect(calls[0].url).toBe(`${SERVER}/auth/token`);
expect(new URLSearchParams(calls[0].body).get('redirect_uri')).toBe(body.redirect_uri);
expect(JSON.parse(cookieStore.get('jmap_rtu_2')!.value)).toEqual({ s: SERVER, c: 'client-from-register' });
});
it('refuses a private address the user did not approve', async () => {
isPublicHttpUrl.mockImplementation(async () => false);
const calls = stubUpstream();
const { POST } = await loadRoute();
const res = (await POST(request('POST', {}, { ...body, custom_server_url: 'http://10.0.0.1' }) as Parameters<typeof POST>[0])) as unknown as { status: number };
expect(res.status).toBe(400);
expect(calls).toHaveLength(0);
});
it('sign-out revokes at the record and removes it', async () => {
cookieStore.set('jmap_rt', 'refresh-1');
cookieStore.set('jmap_rtu', JSON.stringify({ s: SERVER, c: 'client-from-register' }));
const calls = stubUpstream();
const { DELETE } = await loadRoute();
await DELETE(request('DELETE') as Parameters<typeof DELETE>[0]);
expect(calls.map((c) => c.url)).toEqual([`${SERVER}/auth/revoke`]);
expect(cookieStore.get('jmap_rtu')).toBeUndefined();
expect(cookieStore.get('jmap_rt')).toBeUndefined();
});
});
describe('in a server build', () => {
it('ignores a planted record and a custom_server_url', async () => {
config.jmapServerUrl = CONFIGURED;
config.oauthClientId = 'configured-client';
cookieStore.set('jmap_rt', 'refresh-1');
cookieStore.set('jmap_rtu', JSON.stringify({ s: SERVER, c: 'client-from-register' }));
const calls = stubUpstream();
const { PUT } = await loadRoute();
await PUT(request('PUT', { force: 'true' }) as Parameters<typeof PUT>[0]);
expect(calls[0].url).toBe(`${CONFIGURED}/auth/token`);
});
it('still answers 500 on an unexpected failure', async () => {
cookieStore.set('jmap_rt', 'refresh-1');
stubUpstream();
const { PUT } = await loadRoute();
const res = (await PUT(request('PUT', { force: 'true' }) as Parameters<typeof PUT>[0])) as unknown as { status: number };
expect(res.status).toBe(500);
});
});
+147
View File
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* What the login routes do with a private server, with and without the desktop
* build, and whose budget a wrong password spends.
*/
vi.mock('next/server', () => ({
NextResponse: {
json: (data: unknown, init?: { status?: number }) => ({
json: async () => data,
status: init?.status ?? 200,
}),
},
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
}));
vi.mock('next/headers', () => ({
cookies: async () => ({ get: () => undefined, set: vi.fn(), delete: vi.fn(), getAll: () => [] }),
}));
const config: Record<string, unknown> = { allowCustomJmapEndpoint: true };
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback),
},
}));
vi.mock('@/lib/auth/crypto', () => ({
encryptSession: () => 'encrypted-session',
decryptSession: () => null,
decryptPayload: () => null,
encryptPayload: () => 'encrypted',
}));
vi.mock('@/lib/stalwart/auth-context', () => ({
setStalwartAuthContext: vi.fn(),
setStalwartAuthContextInStore: vi.fn(),
clearStalwartAuthContextInStore: vi.fn(),
readStalwartAuthContextFromStore: () => null,
}));
vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: vi.fn() }));
const resolveJmapIdentity = vi.hoisted(() => vi.fn());
vi.mock('@/lib/auth/verify-jmap-auth', async (original) => ({
...(await original<object>()),
resolveJmapIdentity,
}));
const reserveLoginAttempt = vi.hoisted(() => vi.fn());
vi.mock('@/lib/auth/verify-budget', async (original) => ({
...(await original<object>()),
reserveLoginAttempt,
}));
// The route's first import compiles a good part of the app.
vi.setConfig({ testTimeout: 60_000 });
const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins');
const LAN_SERVER = 'http://192.168.1.5:8080';
function loginRequest(serverUrl: string): unknown {
const headers = new Headers({ 'sec-fetch-site': 'same-origin', host: 'app.bulwark.localhost' });
return {
method: 'POST',
headers,
nextUrl: { searchParams: new URLSearchParams(), basePath: '' },
json: async () => ({ serverUrl, username: 'ent-alice@example.org', password: 'pw' }),
};
}
async function postSession(serverUrl: string) {
vi.resetModules();
const { POST } = await import('@/app/api/auth/session/route');
const res = (await POST(loginRequest(serverUrl) as Parameters<typeof POST>[0])) as unknown as {
status: number;
json: () => Promise<Record<string, unknown>>;
};
return { status: res.status, body: await res.json() };
}
beforeEach(() => {
resolveJmapIdentity.mockReset();
resolveJmapIdentity.mockImplementation(async (url: string) => ({ serverUrl: url, accountName: 'ent-alice@example.org' }));
reserveLoginAttempt.mockReset();
reserveLoginAttempt.mockImplementation(async () => async () => {});
(globalThis as unknown as Record<symbol, ReadonlySet<string>>)[TRUST_KEY] = new Set([LAN_SERVER]);
});
afterEach(() => {
vi.unstubAllEnvs();
delete (globalThis as unknown as Record<symbol, unknown>)[TRUST_KEY];
});
describe('POST /api/auth/session', () => {
it('a server build refuses a private URL, even with the set present', async () => {
const { status, body } = await postSession(LAN_SERVER);
expect(status).toBe(400);
expect(body.error).toBe('Server URL is not allowed');
expect(resolveJmapIdentity).not.toHaveBeenCalled();
});
it('a desktop build still refuses a private server the user did not approve', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const { status } = await postSession('http://192.168.1.6:8080');
expect(status).toBe(400);
expect(resolveJmapIdentity).not.toHaveBeenCalled();
});
it('a desktop build reaches an approved private server, and marks it trusted', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const { status } = await postSession(LAN_SERVER);
expect(status).toBe(200);
expect(resolveJmapIdentity).toHaveBeenCalledTimes(1);
expect(resolveJmapIdentity.mock.calls[0][3]).toMatchObject({ trusted: true });
});
});
describe('the login budget', () => {
it('a server build budgets by client address', async () => {
config.jmapServers = [{ id: 'a', url: LAN_SERVER }];
await postSession(LAN_SERVER);
delete config.jmapServers;
expect(reserveLoginAttempt).toHaveBeenCalledWith('0.0.0.0');
});
it('a desktop build budgets per mail server, so two servers do not share one', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
(globalThis as unknown as Record<symbol, ReadonlySet<string>>)[TRUST_KEY] = new Set([LAN_SERVER, 'http://192.168.1.6:8080']);
await postSession(LAN_SERVER);
await postSession('http://192.168.1.6:8080/');
expect(reserveLoginAttempt.mock.calls.map((c) => c[0])).toEqual([
'server:http://192.168.1.5:8080',
'server:http://192.168.1.6:8080',
]);
});
it('the key is no address, so nothing is forwarded upstream as the client', async () => {
const { forwardedClientHeaders } = await import('@/lib/auth/client-address');
expect(forwardedClientHeaders('server:http://192.168.1.5:8080')).toEqual({});
});
});
@@ -0,0 +1,163 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { BulwarkDesktopBridge } from '../types';
/** What the webmail owes the shell: saving on close, leaving nothing behind on sign-out. */
function setBridge(bridge: Partial<BulwarkDesktopBridge> | undefined) {
window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined;
}
afterEach(() => {
setBridge(undefined);
vi.unstubAllEnvs();
vi.resetModules();
});
describe('saving drafts before a window closes', () => {
function lifecycleBridge() {
const handlers = new Set<() => Promise<unknown>>();
setBridge({
version: 1,
platform: 'win32',
lifecycle: {
onFlushRequest: (handler) => {
handlers.add(handler);
return () => handlers.delete(handler);
},
},
});
return handlers;
}
it('asks to be told only while a composer is open', async () => {
const handlers = lifecycleBridge();
const { registerComposeFlush } = await import('@/lib/compose-flush-registry');
expect(handlers.size).toBe(0);
const stop = registerComposeFlush(async () => {});
expect(handlers.size).toBe(1);
const stopSecond = registerComposeFlush(async () => {});
expect(handlers.size).toBe(1);
stop();
expect(handlers.size).toBe(1);
stopSecond();
expect(handlers.size).toBe(0);
});
it('a request runs every composer\'s flush and answers when they are done', async () => {
const handlers = lifecycleBridge();
const { registerComposeFlush } = await import('@/lib/compose-flush-registry');
const saved: string[] = [];
let release = () => {};
const stopA = registerComposeFlush(async () => {
await new Promise<void>((resolve) => (release = resolve));
saved.push('a');
});
const stopB = registerComposeFlush(async () => {
saved.push('b');
});
let answered = false;
const answer = [...handlers][0]().then(() => (answered = true));
await Promise.resolve();
expect(answered).toBe(false);
release();
await answer;
expect(saved.sort()).toEqual(['a', 'b']);
stopA();
stopB();
});
it('a composer that hangs does not hold the answer past its limit', async () => {
vi.useFakeTimers();
try {
const handlers = lifecycleBridge();
const { registerComposeFlush } = await import('@/lib/compose-flush-registry');
const stop = registerComposeFlush(() => new Promise(() => {}));
let answered = false;
void [...handlers][0]().then(() => (answered = true));
await vi.advanceTimersByTimeAsync(3001);
expect(answered).toBe(true);
stop();
} finally {
vi.useRealTimers();
}
});
it('in a browser nothing is asked of a bridge that is not there', async () => {
const { registerComposeFlush } = await import('@/lib/compose-flush-registry');
expect(() => registerComposeFlush(async () => {})()).not.toThrow();
});
});
describe('the last account on a server', () => {
async function accountsOn(...servers: string[]) {
const { useAccountStore } = await import('@/stores/account-store');
useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null });
const base = { rememberMe: true, lastLoginAt: 0, isConnected: true, hasError: false, isDefault: false, authMode: 'basic' as const };
const ids = servers.map((serverUrl, index) =>
useAccountStore.getState().addAccount({ ...base, label: `u${index}`, serverUrl, username: `u${index}`, displayName: `u${index}`, email: `u${index}@example.org` }),
);
return { ids, remove: (id: string) => useAccountStore.getState().removeAccount(id) };
}
function forgetBridge() {
const forget = vi.fn();
setBridge({ version: 1, platform: 'win32', servers: { prepare: vi.fn(), noteSession: vi.fn(), forget } });
return forget;
}
it('leaving takes the server off the list', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const forget = forgetBridge();
const { ids, remove } = await accountsOn('https://mail.example.org');
remove(ids[0]);
expect(forget).toHaveBeenCalledWith('https://mail.example.org', 'signed-out');
});
it('with another account still there, it stays', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const forget = forgetBridge();
const { ids, remove } = await accountsOn('https://mail.example.org', 'https://mail.example.org/');
remove(ids[0]);
expect(forget).not.toHaveBeenCalled();
remove(ids[1]);
expect(forget).toHaveBeenCalledTimes(1);
});
it('outside the desktop build nothing is called', async () => {
const forget = forgetBridge();
const { ids, remove } = await accountsOn('https://mail.example.org');
remove(ids[0]);
expect(forget).not.toHaveBeenCalled();
});
});
describe('a plain http account in the desktop build', () => {
async function client(serverUrl: string) {
const { JMAPClient } = await import('@/lib/jmap/client');
const instance = new JMAPClient(serverUrl, 'u', 'p');
(instance as unknown as { session: unknown }).session = { eventSourceUrl: `${serverUrl}/jmap/eventsource/`, capabilities: {} };
return instance;
}
it('polls: no push stream to pin the app origin\'s sockets', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect((await client('http://192.0.2.5:8080')).getEventSourceUrl()).toBeNull();
}, 60_000);
it('an https account keeps its stream', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect((await client('https://mail.example.org')).getEventSourceUrl()).toBe('https://mail.example.org/jmap/eventsource/');
}, 60_000);
it('a server build keeps it for any server', async () => {
expect((await client('http://192.0.2.5:8080')).getEventSourceUrl()).toBe('http://192.0.2.5:8080/jmap/eventsource/');
}, 60_000);
it('a blob gets back the type the route took off', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const { withDesktopBlobType } = await import('../jmap-route');
const blob = new Blob(['x'], { type: 'application/octet-stream' });
expect(withDesktopBlobType(blob, 'http://192.0.2.5:8080', 'image/png').type).toBe('image/png');
expect(withDesktopBlobType(blob, 'https://mail.example.org', 'image/png')).toBe(blob);
});
});
+120
View File
@@ -0,0 +1,120 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { build } from 'esbuild';
import { desktopBuildFlag } from '../../../scripts/desktop-build-flag.mjs';
const ROOT = path.resolve(__dirname, '../../..');
const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins');
const PRIVATE_SERVER = 'http://192.168.1.5:8080';
type Holder = Record<symbol, ReadonlySet<string> | undefined>;
function trust(...origins: string[]) {
(globalThis as unknown as Holder)[TRUST_KEY] = new Set(origins);
}
async function loadResolver() {
vi.resetModules();
return (await import('@/lib/admin/jmap-servers')).resolveTrustedJmapUrl;
}
beforeEach(() => {
delete (globalThis as unknown as Holder)[TRUST_KEY];
});
afterEach(() => {
vi.unstubAllEnvs();
delete (globalThis as unknown as Holder)[TRUST_KEY];
});
describe('the trusted set, server build', () => {
it('refuses a private URL even when the set holds it', async () => {
trust('http://192.168.1.5:8080');
const resolve = await loadResolver();
expect(resolve(PRIVATE_SERVER, '', [])).toBeNull();
});
});
describe('the trusted set, desktop build', () => {
beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
});
it('accepts a server main approved', async () => {
trust('http://192.168.1.5:8080');
const resolve = await loadResolver();
expect(resolve(`${PRIVATE_SERVER}/`, '', [])).toBe(PRIVATE_SERVER);
});
it('does not trust everything: another private server stays out', async () => {
trust('http://192.168.1.5:8080');
const resolve = await loadResolver();
expect(resolve('http://192.168.1.6:8080', '', [])).toBeNull();
expect(resolve('http://192.168.1.5:9999', '', [])).toBeNull();
expect(resolve('http://169.254.169.254', '', [])).toBeNull();
});
it('trusts nothing before main has sent a set', async () => {
const resolve = await loadResolver();
expect(resolve(PRIVATE_SERVER, '', [])).toBeNull();
});
it('ignores a URL with credentials in it', async () => {
trust('http://192.168.1.5:8080');
const resolve = await loadResolver();
expect(resolve('http://a:b@192.168.1.5:8080', '', [])).toBeNull();
});
});
describe('the flag is fixed at build time', () => {
/** Compiles lib/desktop/flag.ts the way next.config.ts `env` does and runs it. */
async function flagOfBuild(buildEnv: Record<string, string>, runtimeEnv: Record<string, string>): Promise<boolean> {
const result = await build({
entryPoints: [path.join(ROOT, 'lib/desktop/flag.ts')],
bundle: true,
write: false,
format: 'cjs',
platform: 'node',
define: { 'process.env.NEXT_PUBLIC_BULWARK_DESKTOP': JSON.stringify(desktopBuildFlag(buildEnv as unknown as NodeJS.ProcessEnv)) },
});
const run = new Function('module', 'exports', 'process', result.outputFiles[0].text);
const module = { exports: {} as { IS_DESKTOP_BUILD?: boolean } };
run(module, module.exports, { env: runtimeEnv });
return module.exports.IS_DESKTOP_BUILD === true;
}
it('a runtime env var cannot switch a server build on', async () => {
expect(await flagOfBuild({}, { NEXT_PUBLIC_BULWARK_DESKTOP: '1' })).toBe(false);
});
it('a desktop build is on without any runtime env var', async () => {
expect(await flagOfBuild({ NEXT_PUBLIC_BULWARK_DESKTOP: '1' }, {})).toBe(true);
});
it('next.config.ts always inlines the flag, so an unset one is no runtime read', () => {
const config = readFileSync(path.join(ROOT, 'next.config.ts'), 'utf8');
expect(config).toMatch(/NEXT_PUBLIC_BULWARK_DESKTOP: desktopBuildFlag\(\)/);
expect(desktopBuildFlag({} as unknown as NodeJS.ProcessEnv)).toBe('');
});
it('flag.ts reads the variable in the one form the compiler replaces', () => {
const source = readFileSync(path.join(ROOT, 'lib/desktop/flag.ts'), 'utf8');
expect(source).toContain("process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1'");
});
it('the trust check uses the form the bundler can drop', () => {
for (const file of ['lib/admin/jmap-servers.ts', 'app/api/auth/totp-token-exchange/route.ts']) {
const source = readFileSync(path.join(ROOT, file), 'utf8');
expect(source, file).toMatch(/process\.env\.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && isDesktopTrustedServer/);
expect(source, file).not.toMatch(/IS_DESKTOP_BUILD && isDesktopTrustedServer/);
}
});
it('no image build sets it', () => {
for (const file of ['Dockerfile', 'Dockerfile.lite']) {
expect(readFileSync(path.join(ROOT, file), 'utf8')).not.toContain('NEXT_PUBLIC_BULWARK_DESKTOP');
}
});
});
+10
View File
@@ -0,0 +1,10 @@
/**
* Routes with no meaning in the desktop build: pairing a phone and the mobile
* hand-off both hand out `https://app.bulwark.localhost`, which nothing else
* can reach, and the app has no use for impersonation. proxy.ts answers 404.
*/
const BLOCKED = /^\/api\/auth\/(pair|handoff|impersonate)(\/|$)/;
export function isDesktopBlockedRoute(pathname: string): boolean {
return BLOCKED.test(pathname);
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Finds the desktop app's bridge. Everything here is feature detected: in a
* browser there's no bridge, and a newer bridge version than this code knows
* is treated as absent rather than guessed at.
*/
import { DESKTOP_BRIDGE_VERSION, type BulwarkDesktopBridge } from './types';
export function getDesktopBridge(): BulwarkDesktopBridge | null {
if (typeof window === 'undefined') return null;
const bridge = window.bulwarkDesktop;
if (!bridge || typeof bridge !== 'object') return null;
if (typeof bridge.version !== 'number' || bridge.version > DESKTOP_BRIDGE_VERSION) return null;
return bridge;
}
/** Nothing added for this server stays: see DesktopServersBridge. */
export function forgetDesktopServer(serverUrl: string, reason: 'failed' | 'signed-out'): void {
getDesktopBridge()?.servers?.forget?.(serverUrl, reason);
}
/** True in any window of the desktop app, standalone or on an instance. */
export function isDesktopApp(): boolean {
return getDesktopBridge() !== null;
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Sign-in through the system browser in the desktop build, for any Stalwart.
*
* A window on app.bulwark.localhost can't be a redirect target: Stalwart 1.0
* refuses it for a public client, and identity providers won't embed. So the
* Electron main process listens on http://127.0.0.1:<port> for one redirect and
* hands the code back. The rest is the regular PKCE flow: the same
* sessionStorage keys, then the callback page, which reads the stored redirect
* URI because it can't be rebuilt from this page's origin.
*
* Google and Entra can't work here. Their clients are registered by whoever
* owns them, and in standalone mode nobody does. Those need an instance.
*/
import { apiFetch } from '@/lib/browser-navigation';
import { generateCodeChallenge, generateCodeVerifier, generateState } from '@/lib/oauth/pkce';
import { getDesktopBridge } from './bridge';
export const DESKTOP_REDIRECT_URI_KEY = 'oauth_redirect_uri';
export const DESKTOP_CLIENT_ID_KEY = 'oauth_client_id';
export type DesktopBrowserSignInOutcome =
| { kind: 'callback'; callbackUrl: string }
| { kind: 'unavailable' }
| { kind: 'declined' }
| { kind: 'cancelled' }
| { kind: 'failed'; error: string };
interface DesktopOAuthAnswer {
authorization_endpoint: string;
client_id: string;
scope: string;
}
export function browserSignInAvailable(): boolean {
return !!getDesktopBridge()?.auth;
}
export async function runDesktopBrowserSignIn(options: {
serverUrl: string;
callbackPath: string;
slot: number;
remember: boolean;
addAccount: boolean;
}): Promise<DesktopBrowserSignInOutcome> {
const bridge = getDesktopBridge();
if (!bridge?.auth) return { kind: 'unavailable' };
const { serverUrl, callbackPath, slot, remember, addAccount } = options;
const prepared = await bridge.servers?.prepare(serverUrl);
if (prepared && !prepared.ok) return { kind: 'declined' };
const { flowId, redirectUri } = await bridge.auth.beginBrowserSignIn();
try {
const res = await apiFetch('/api/auth/desktop-oauth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverUrl, redirectUri }),
});
if (!res.ok) {
bridge.auth.cancelBrowserSignIn(flowId);
return { kind: 'unavailable' };
}
const answer = (await res.json()) as DesktopOAuthAnswer;
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = generateState();
const authorizeUrl = new URL(answer.authorization_endpoint);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', answer.client_id);
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
authorizeUrl.searchParams.set('scope', answer.scope);
authorizeUrl.searchParams.set('state', state);
authorizeUrl.searchParams.set('code_challenge', challenge);
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
if (addAccount) authorizeUrl.searchParams.set('prompt', 'select_account');
const result = await bridge.auth.completeBrowserSignIn(flowId, authorizeUrl.toString());
if (!result.ok) {
return result.error === 'cancelled' ? { kind: 'cancelled' } : { kind: 'failed', error: result.error };
}
if (result.state !== state) return { kind: 'failed', error: 'state_mismatch' };
// What the callback page expects from a same-window flow, plus the two
// values only this flow has.
sessionStorage.setItem('oauth_code_verifier', verifier);
sessionStorage.setItem('oauth_state', state);
sessionStorage.setItem('oauth_server_url', serverUrl);
sessionStorage.removeItem('oauth_server_id');
sessionStorage.setItem('oauth_remember', String(remember));
sessionStorage.setItem('oauth_cookie_slot', String(slot));
sessionStorage.setItem(DESKTOP_REDIRECT_URI_KEY, redirectUri);
sessionStorage.setItem(DESKTOP_CLIENT_ID_KEY, answer.client_id);
if (addAccount) sessionStorage.setItem('oauth_add_account_mode', 'true');
const query = new URLSearchParams({ code: result.code, state: result.state });
return { kind: 'callback', callbackUrl: `${callbackPath}?${query}` };
} catch (error) {
bridge.auth.cancelBrowserSignIn(flowId);
return { kind: 'failed', error: error instanceof Error ? error.message : String(error) };
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* The desktop app's own webmail build (`npm run build:desktop`).
*
* A build-time constant, inlined through next.config.ts `env` into client and
* server bundles alike, like `IS_LITE`. A server admin can't switch it on: in
* any other build the value is compiled in as empty, and the bundler drops
* the branches. Never read this flag through a runtime environment variable.
*
* What it unlocks, and only in that build:
* - login routes accept the servers the user approved in the app
* (`isDesktopTrustedServer`, the list comes from the Electron main process)
* - sign-in through the system browser for any server
* - a per-slot record of the upstream for refresh tokens
* - a login budget per mail server rather than per client address
* - pairing and hand-off routes are gone
*/
export const IS_DESKTOP_BUILD = process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1';
+55
View File
@@ -0,0 +1,55 @@
/**
* Where the page reaches a plain http mail server in the desktop app.
*
* The app's page is https, so Chromium blocks a fetch to a plain http address as
* mixed content, whatever the CSP says. The embedded server forwards
* `/__jmap/<id>/...` to servers the user approved (src/server/jmap-proxy.ts in
* the desktop repo), where the id is the base64url of the server's origin. The
* real URL stays in the account registry and the cookies, since the server
* routes reach the mail server directly: only the page's own requests move.
*/
import { IS_DESKTOP_BUILD } from './flag';
export function desktopJmapOriginId(origin: string): string {
const bytes = new TextEncoder().encode(origin);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/**
* The base URL the page uses to talk to `serverUrl`: the same URL unless this
* is the desktop build and the server speaks plain http.
*/
export function desktopJmapBase(serverUrl: string): string {
if (!IS_DESKTOP_BUILD || typeof window === 'undefined') return serverUrl;
try {
const url = new URL(serverUrl);
if (url.protocol !== 'http:') return serverUrl;
const path = url.pathname.replace(/\/+$/, '');
return `${window.location.origin}/__jmap/${desktopJmapOriginId(url.origin)}${path}`;
} catch {
return serverUrl;
}
}
/** True for a server the page reaches through the embedded server. */
export function isDesktopProxiedServer(serverUrl: string): boolean {
return desktopJmapBase(serverUrl) !== serverUrl;
}
/**
* The route answers everything but JSON as octet-stream, so that nothing can render
* on the app's origin. A blob for the page gets back the type it asked for.
*/
export function withDesktopBlobType(blob: Blob, serverUrl: string, type: string | undefined): Blob {
return type && isDesktopProxiedServer(serverUrl) ? new Blob([blob], { type }) : blob;
}
/** The origin part of {@link desktopJmapBase}, onto which session URLs are moved. */
export function desktopJmapOrigin(serverUrl: string): string {
const { origin, protocol } = new URL(serverUrl);
if (!IS_DESKTOP_BUILD || typeof window === 'undefined' || protocol !== 'http:') return origin;
return `${window.location.origin}/__jmap/${desktopJmapOriginId(origin)}`;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* The key the sign-in routes budget failures on, in the desktop build.
*
* There's one client on the embedded server, so a client key would make every
* account on the machine share five failures per 15 minutes: a stale password
* on one server would lock sign-in on the others. What the budget protects is
* the mail server's failure allowance for this address, so it counts per
* server. The value is no IP address, so forwardedClientHeaders passes nothing
* upstream.
*/
export function desktopLoginBudgetKey(serverUrl: unknown): string {
try {
return `server:${new URL(String(serverUrl)).origin}`;
} catch {
return 'server:invalid';
}
}
+33
View File
@@ -0,0 +1,33 @@
/**
* The mail servers the user approved in the desktop app.
*
* The Electron main process owns this list and sends it to the embedded server
* at start and on every change (src/server/host.ts in the desktop repo sets it
* on globalThis, which all of Next's bundles share). Nothing in the renderer
* or the environment can write it, so script in a page can't aim the login
* routes at the LAN.
*
* Callers guard this with IS_DESKTOP_BUILD, so a server build compiles the
* whole thing away. scripts/standalone-guard.mjs checks that it did.
*/
type TrustedOrigins = ReadonlySet<string>;
function originOf(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
if (parsed.username || parsed.password) return null;
return parsed.origin;
} catch {
return null;
}
}
export function isDesktopTrustedServer(url: string | null | undefined): boolean {
if (!url) return false;
const origin = originOf(url);
if (!origin) return false;
const holder = globalThis as unknown as Record<symbol, TrustedOrigins | undefined>;
return holder[Symbol.for('bulwark.desktop.trustedJmapOrigins')]?.has(origin) === true;
}
+111 -5
View File
@@ -2,21 +2,127 @@
* The desktop app's preload bridge as the webmail sees it. Feature checks ask
* `window.bulwarkDesktop?.<area>`; in a browser the object isn't there.
*
* Only what the webmail uses so far. The desktop shell fills it in.
* The shell side lives in the desktop repo (src/shared/bridge.ts), a copy of
* this contract. Add a member here and there together. A breaking change bumps
* `version`; adding a member doesn't.
*/
export const DESKTOP_BRIDGE_VERSION = 1;
export type DesktopWindowType = 'main' | 'message' | 'composer' | 'settings';
export interface DesktopWindowInfo {
id: number;
type: DesktopWindowType;
title: string;
focused: boolean;
}
export interface DesktopWindowsBridge {
/**
* Opens a pop-out window itself. A `name` that is already open focuses that
* window and doesn't load `url` again: one window per message. Left out, the
* page uses window.open with a features string, which the shell's
* window-open handler sees too.
* Opens a pop-out window itself. The name is the window's identity: opening
* the same name again focuses the window that has it. Left out, the page uses
* window.open with a features string, which the shell's window-open handler
* sees too.
*/
open?: (url: string, options: { name: string; width: number; height: number }) => void;
/** One window per message: opening it again focuses it. */
openMessage?: (target: { url: string; emailId: string }) => void;
openComposer?: (target: { url: string; name?: string }) => void;
/** Focuses a window from `list()`, or the main window with no id. */
focus?: (id?: number) => void;
list?: () => Promise<DesktopWindowInfo[]>;
/** Fires when a window opens, closes, retitles or takes the focus. Returns the unsubscribe. */
onChanged?: (listener: (windows: DesktopWindowInfo[]) => void) => () => void;
}
export interface DesktopThemeBridge {
/** The page's colours, for title bar buttons and the first frame of new windows. */
set: (theme: { dark: boolean; background: string; foreground?: string }) => void;
/** Fires when the OS switches between light and dark. Returns the unsubscribe. */
onSystemChange?: (listener: (system: { dark: boolean; highContrast: boolean }) => void) => () => void;
}
export interface DesktopMenuBridge {
/**
* What the app menu shows as enabled or checked, by command id. The shell
* keeps the last state per window and applies it when that window has focus.
*/
setState: (state: Record<string, boolean | { enabled?: boolean; checked?: boolean }>) => void;
}
export interface DesktopShellBridge {
/** http, https and mailto only. */
openExternal: (url: string) => void;
}
export type DesktopServerPrepareResult =
| { ok: true; approved: boolean }
| { ok: false; reason: 'declined' | 'invalid' };
export interface DesktopServersBridge {
/**
* Call before signing in to a server. Asks the user first when it is on the
* LAN or plain http, and lets the app reach it. Standalone mode only.
*/
prepare: (serverUrl: string) => Promise<DesktopServerPrepareResult>;
/**
* The sign-in worked, so the server stays: what `prepare` added lasts only until
* the app quits before this. Other origins a session names get a prompt.
*/
noteSession: (
serverUrl: string,
urls: { apiUrl?: string; downloadUrl?: string; uploadUrl?: string; eventSourceUrl?: string },
) => void;
/** A sign-in that failed, or the last account on this server signing out. */
forget?: (serverUrl: string, reason: 'failed' | 'signed-out') => void;
}
export interface DesktopLifecycleBridge {
/**
* Before a window closes or the app quits, the shell asks the page to save what
* it has and waits a few seconds. A window with no handler closes at once.
*/
onFlushRequest: (handler: () => Promise<unknown>) => () => void;
}
export type DesktopBrowserSignInResult =
| { ok: true; code: string; state: string }
| { ok: false; error: string };
export interface DesktopAuthBridge {
/** Starts a short-lived loopback listener for one sign-in. */
beginBrowserSignIn: () => Promise<{ flowId: string; redirectUri: string }>;
/** Opens the system browser on `authorizeUrl` and waits for the redirect. */
completeBrowserSignIn: (flowId: string, authorizeUrl: string) => Promise<DesktopBrowserSignInResult>;
cancelBrowserSignIn: (flowId: string) => void;
}
export type DesktopPluginInstallResult =
| { ok: true; id: string; version: string }
| { ok: false; error: string; code?: string };
export interface DesktopPluginsBridge {
/**
* Installs from the directory through the local admin API, with its signature
* and hash checks, after a native prompt. No version means the latest. The
* renderer never holds an admin session. Standalone only.
*/
install: (slug: string, version?: string) => Promise<DesktopPluginInstallResult>;
}
export interface BulwarkDesktopBridge {
version: number;
platform: 'win32' | 'darwin' | 'linux';
mode?: 'standalone' | 'instance';
windows?: DesktopWindowsBridge;
theme?: DesktopThemeBridge;
menu?: DesktopMenuBridge;
shell?: DesktopShellBridge;
lifecycle?: DesktopLifecycleBridge;
servers?: DesktopServersBridge;
auth?: DesktopAuthBridge;
plugins?: DesktopPluginsBridge;
}
declare global {
+24 -5
View File
@@ -1,4 +1,6 @@
import { recordJmapCall, reportJmapFailure } from "@/lib/error-reporting/client";
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { desktopJmapBase, desktopJmapOrigin, isDesktopProxiedServer, withDesktopBlobType } from '@/lib/desktop/jmap-route';
import { advertisesMailboxMetadata, bulwarkEntries, escapeMetadataPathSegment, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataPatchError, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
import { IncompleteDeleteError } from "./delete-error";
import { downloadAllowed, saveBlobAs, type DownloadKind } from "@/lib/download-guard";
@@ -1910,7 +1912,7 @@ export class JMAPClient implements IJMAPClient {
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
response = await this.timedFetch(url, init, retryHeaders, timeoutMs);
}
} else if (this.authMode === 'basic' && !this.reconnecting && url !== `${this.serverUrl}/.well-known/jmap`) {
} else if (this.authMode === 'basic' && !this.reconnecting && url !== this.discoveryUrl()) {
// JMAP session may have expired - re-establish and retry once
this.reconnecting = true;
try {
@@ -1960,7 +1962,7 @@ export class JMAPClient implements IJMAPClient {
* and retry directly against the final URL so we can re-send Authorization.
*/
private async fetchSessionResponse(): Promise<Response> {
const discoveryUrl = `${this.serverUrl}/.well-known/jmap`;
const discoveryUrl = this.discoveryUrl();
const response = await this.authenticatedFetch(discoveryUrl, { method: 'GET' });
// A proxy in front of the session URL answers the header-less redirected
// request with 401 instead of Stalwart's silent 200 - the credentials are
@@ -2003,7 +2005,7 @@ export class JMAPClient implements IJMAPClient {
async connect(): Promise<void> {
if (this.offlineOnly) throw new OfflineUnavailableError();
this.intentionallyDisconnected = false;
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
const sessionUrl = this.discoveryUrl();
try {
const sessionResponse = await this.fetchSessionResponse();
@@ -2160,10 +2162,15 @@ export class JMAPClient implements IJMAPClient {
this.capabilities = {};
}
/** Same as serverUrl, except for a plain http server in the desktop app (lib/desktop/jmap-route.ts). */
private discoveryUrl(): string {
return `${desktopJmapBase(this.serverUrl)}/.well-known/jmap`;
}
private rewriteSessionUrl(url: string): string {
if (!url) return url;
try {
const { origin } = new URL(this.serverUrl);
const origin = desktopJmapOrigin(this.serverUrl);
if (!/^(https?:)?\/\//i.test(url)) {
return origin + (url.startsWith('/') ? url : '/' + url);
}
@@ -2196,6 +2203,14 @@ export class JMAPClient implements IJMAPClient {
if (session.eventSourceUrl) {
session.eventSourceUrl = rewrite(session.eventSourceUrl);
}
// A download or upload origin of its own needs the CORS headers the desktop
// app adds for this account's servers.
getDesktopBridge()?.servers?.noteSession(this.serverUrl, {
apiUrl: session.apiUrl,
downloadUrl: session.downloadUrl,
uploadUrl: session.uploadUrl,
eventSourceUrl: session.eventSourceUrl,
});
}
/**
@@ -6508,7 +6523,7 @@ export class JMAPClient implements IJMAPClient {
if (!response.ok) {
throw new Error(`Failed to fetch blob: ${response.status}`);
}
const blob = await response.blob();
const blob = withDesktopBlobType(await response.blob(), this.serverUrl, type);
getOfflineHooks()?.recordBlob(this, blobId, accountId || this.accountId, blob);
return blob;
}
@@ -6991,6 +7006,10 @@ export class JMAPClient implements IJMAPClient {
getEventSourceUrl(): string | null {
if (!this.session) return null;
// A plain http server in the desktop app shares the app's own origin, on
// http/1.1 and six sockets, with every window. Push streams pinning those
// would stall the app, so these accounts poll (setupPushNotifications).
if (isDesktopProxiedServer(this.serverUrl)) return null;
// RFC 8620: session root level, with fallback to capabilities for some servers
const coreCapability = this.session.capabilities?.["urn:ietf:params:jmap:core"] as { eventSourceUrl?: string } | undefined;
+72
View File
@@ -0,0 +1,72 @@
/**
* Which server minted a slot's refresh token, for the desktop build.
*
* The token routes know a server only through `jmap_rts`, the id of an entry in
* `jmapServers`, and otherwise fall back to the configured server. A standalone
* desktop app has neither: every account is a server the user typed. So refresh,
* revocation and sign-out would throw "OAuth misconfigured", and the account
* would first look unreachable and then be signed out.
*
* The record is sealed so only this server can have written it. Whether the
* address may be reached isn't stored: it is checked again on every read, by
* the same rule as the sign-in routes.
*/
import type { cookies } from 'next/headers';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
import { getSignInCookieOptions } from '@/lib/oauth/cookie-config';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
type CookieStore = Awaited<ReturnType<typeof cookies>>;
const COOKIE = 'jmap_rtu';
export interface CustomUpstream {
serverUrl: string;
clientId: string;
/** The user approved this server, so it may be on a private address. */
trusted: boolean;
}
export function customUpstreamCookieName(slot: number): string {
return slot === 0 ? COOKIE : `${COOKIE}_${slot}`;
}
/** Null when the address isn't one the sign-in routes would accept right now. */
export async function resolveCustomUpstream(serverUrl: unknown, clientId: unknown): Promise<CustomUpstream | null> {
if (typeof serverUrl !== 'string' || !serverUrl || typeof clientId !== 'string' || !clientId) return null;
await configManager.ensureLoaded();
const servers = parseJmapServers(configManager.get<unknown>('jmapServers', []));
const trusted = resolveTrustedJmapUrl(serverUrl, configManager.get<string>('jmapServerUrl', ''), servers) !== null;
if (!trusted && !(await isPublicHttpUrl(serverUrl))) return null;
return { serverUrl: serverUrl.replace(/\/+$/, ''), clientId, trusted };
}
export function writeCustomUpstream(
cookieStore: CookieStore,
slot: number,
upstream: Pick<CustomUpstream, 'serverUrl' | 'clientId'>,
remember: boolean,
): void {
const value = encryptPayload({ s: upstream.serverUrl, c: upstream.clientId }, 'custom-upstream');
cookieStore.set(customUpstreamCookieName(slot), value, getSignInCookieOptions(remember));
}
export async function readCustomUpstream(cookieStore: CookieStore, slot: number): Promise<CustomUpstream | null> {
const value = cookieStore.get(customUpstreamCookieName(slot))?.value;
if (!value) return null;
let payload: Record<string, unknown> | null;
try {
payload = decryptPayload(value, 'custom-upstream');
} catch {
return null;
}
return payload ? resolveCustomUpstream(payload.s, payload.c) : null;
}
export function clearCustomUpstream(cookieStore: CookieStore, slot: number): void {
const name = customUpstreamCookieName(slot);
if (cookieStore.get(name)) cookieStore.delete(name);
}
+17 -4
View File
@@ -8,6 +8,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers';
import { stalwartDefaultClientId } from '@/lib/auth/stalwart-password-login';
import { getOauthResource } from '@/lib/oauth/tokens';
import type { CustomUpstream } from '@/lib/oauth/custom-upstream';
// Fallback OAuth client id used when no client is configured. The password+TOTP
// login route mints tokens against the mail server's built-in OAuth with this
@@ -25,6 +26,12 @@ export interface ClientConfigOptions {
* so a misconfiguration surfaces at login rather than as a broken session.
*/
fallbackClientId?: string;
/**
* The server this token belongs to when it's in no configured list: the
* desktop app's accounts (lib/oauth/custom-upstream.ts). Wins over `serverId`
* and the global server, and the configured client secret is never sent to it.
*/
upstream?: CustomUpstream | null;
}
function httpOrigin(url: string): string | null {
@@ -101,6 +108,10 @@ function discoveryBase(url: string): string {
}
export function getRequiredConfig(serverId?: string | null, options?: ClientConfigOptions) {
if (options?.upstream) {
const { clientId, serverUrl } = options.upstream;
return { clientId, serverUrl, discoveryUrl: discoveryBase(serverUrl), serverId: undefined, endSessionUrl: undefined, resource: undefined };
}
const entry = getServerEntry(serverId);
const globalClientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
@@ -139,7 +150,8 @@ export function getRequiredConfig(serverId?: string | null, options?: ClientConf
* with a client it registers itself (lib/auth/stalwart-password-login.ts),
* whose id the refresh must present instead of the default one.
*/
export async function defaultClientOptions(serverId?: string | null): Promise<ClientConfigOptions> {
export async function defaultClientOptions(serverId?: string | null, upstream?: CustomUpstream | null): Promise<ClientConfigOptions> {
if (upstream) return { upstream };
const { clientId, serverUrl } = getRequiredConfig(serverId, { fallbackClientId: DEFAULT_CLIENT_ID });
if (clientId !== DEFAULT_CLIENT_ID) return { fallbackClientId: DEFAULT_CLIENT_ID };
return { fallbackClientId: await stalwartDefaultClientId(serverUrl, DEFAULT_CLIENT_ID, true) };
@@ -177,7 +189,7 @@ export async function getMetadata(serverId?: string | null, options?: ClientConf
export function buildOAuthParams(base: Record<string, string>, serverId?: string | null, options?: ClientConfigOptions): URLSearchParams {
const { clientId } = getRequiredConfig(serverId, options);
const params = new URLSearchParams({ ...base, client_id: clientId });
const secret = getClientSecret(serverId);
const secret = options?.upstream ? '' : getClientSecret(serverId);
if (secret) {
params.set('client_secret', secret);
}
@@ -197,15 +209,16 @@ export async function exchangeCodeForTokens(
codeVerifier: string,
redirectUri: string,
serverId?: string | null,
options?: ClientConfigOptions,
): Promise<TokenResult> {
const tokenEndpoint = await getTokenEndpoint(serverId);
const tokenEndpoint = await getTokenEndpoint(serverId, options);
const params = buildOAuthParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}, serverId);
}, serverId, options);
// The body carries the code, the PKCE verifier and the client secret. The
// endpoint was validated; a redirect would re-send all of it to a host
+2 -1
View File
@@ -20,6 +20,7 @@
*/
import { IS_LITE } from '@/lib/lite';
import { desktopJmapBase } from '@/lib/desktop/jmap-route';
import { devicesForAccount } from '@/lib/device-policy';
import { accountSubjectPending, useAccountSubjects } from '@/lib/org/account-subjects';
import { labelsPolicyFor } from '@/lib/outgoing/classification';
@@ -325,7 +326,7 @@ export function probeServer(serverUrl: string): Promise<boolean> {
if (running) return running;
const probe = (async () => {
try {
await fetch(`${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit' });
await fetch(`${desktopJmapBase(serverUrl.replace(/\/+$/, ''))}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit' });
if (setServerDown(serverUrl, false)) scheduleFlushAll(500);
return false;
} catch {
+12
View File
@@ -1,3 +1,5 @@
import { getDesktopBridge } from '@/lib/desktop/bridge';
/**
* Keeps <meta name="theme-color"> in step with the active theme.
*
@@ -66,6 +68,7 @@ export function syncThemeColorMeta(): void {
const run = () => {
scheduled = false;
writeMeta();
tellDesktopShell();
};
if (typeof requestAnimationFrame === 'function') {
@@ -75,6 +78,15 @@ export function syncThemeColorMeta(): void {
}
}
/** The desktop app's title bar buttons and new windows' first frame follow the page. */
function tellDesktopShell(): void {
const theme = getDesktopBridge()?.theme;
if (!theme) return;
const background = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')?.content;
if (!background) return;
theme.set({ dark: document.documentElement.classList.contains('dark'), background });
}
/** Turn syncing on and take a first reading. Called by <ThemeColorSync />. */
export function enableThemeColorSync(): void {
enabled = true;
+3
View File
@@ -5,6 +5,7 @@
// register endpoint we hit on the relay.
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { isDesktopApp } from '@/lib/desktop/bridge';
import type { AccountInfo, EmailPushConfig, Mailbox } from '@/lib/jmap/types';
import { isJmapSetError, jmapErrorType } from '@/lib/jmap/jmap-error';
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
@@ -443,6 +444,8 @@ export function isWebPushSupported(): boolean {
// No service worker is registered in the static Lite build (see
// components/service-worker-registration.tsx), so push cannot be enabled.
if (IS_LITE) return false;
// Electron has the API, but subscribing always fails: it has no push service.
if (isDesktopApp()) return false;
return (
'serviceWorker' in navigator &&
'PushManager' in window &&
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "تم نقل هذه الرسالة إلى مجلد آخر.",
"message_moved_to": "تم نقل هذه الرسالة إلى {folder}.",
"window_not_ready": "لم تفتح النافذة الجديدة في الوقت المناسب. تبقى مسودتك هنا."
},
"desktop_sign_in": {
"browser_button": "سجّل الدخول باستخدام المتصفح",
"browser_waiting": "أكمل تسجيل الدخول في المتصفح",
"browser_unavailable": "لا يتيح هذا الخادم تسجيل الدخول عبر المتصفح. استخدم كلمة المرور بدلًا من ذلك.",
"browser_failed": "لم ينجح تسجيل الدخول عبر المتصفح. حاول مرة أخرى.",
"server_needed": "أدخل عنوان الخادم أولًا.",
"server_declined": "لم تسمح بهذا الخادم، لذا لم يُرسل إليه أي شيء.",
"server_invalid": "عنوان الخادم هذا غير صالح."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Aquest missatge s’ha mogut a una altra carpeta.",
"message_moved_to": "Aquest missatge s’ha mogut a {folder}.",
"window_not_ready": "La finestra nova no s’ha obert a temps. L’esborrany es queda aquí."
},
"desktop_sign_in": {
"browser_button": "Inicia la sessió amb el navegador",
"browser_waiting": "Acaba d'iniciar la sessió al navegador",
"browser_unavailable": "Aquest servidor no permet iniciar la sessió amb el navegador. Fes servir la contrasenya.",
"browser_failed": "No s'ha pogut iniciar la sessió amb el navegador. Torna-ho a provar.",
"server_needed": "Primer escriu l'adreça del servidor.",
"server_declined": "No has permès aquest servidor, així que no s'hi ha enviat res.",
"server_invalid": "Aquesta adreça de servidor no és vàlida."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Tato zpráva byla přesunuta do jiné složky.",
"message_moved_to": "Tato zpráva byla přesunuta do {folder}.",
"window_not_ready": "Nové okno se neotevřelo včas. Koncept zůstává zde."
},
"desktop_sign_in": {
"browser_button": "Přihlásit se v prohlížeči",
"browser_waiting": "Dokončete přihlášení v prohlížeči",
"browser_unavailable": "Tento server přihlášení přes prohlížeč nenabízí. Použijte heslo.",
"browser_failed": "Přihlášení přes prohlížeč se nezdařilo. Zkuste to znovu.",
"server_needed": "Nejdřív zadejte adresu serveru.",
"server_declined": "Tento server jste nepovolili, proto se na něj nic neodeslalo.",
"server_invalid": "Tato adresa serveru není platná."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Denne besked er flyttet til en anden mappe.",
"message_moved_to": "Denne besked er flyttet til {folder}.",
"window_not_ready": "Det nye vindue åbnede ikke i tide. Din kladde bliver her."
},
"desktop_sign_in": {
"browser_button": "Log ind med din browser",
"browser_waiting": "Gør login færdigt i din browser",
"browser_unavailable": "Denne server tilbyder ikke login via browser. Brug din adgangskode i stedet.",
"browser_failed": "Login via browseren lykkedes ikke. Prøv igen.",
"server_needed": "Angiv først serverens adresse.",
"server_declined": "Du tillod ikke denne server, så der blev ikke sendt noget til den.",
"server_invalid": "Denne serveradresse er ikke gyldig."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Diese Nachricht wurde in einen anderen Ordner verschoben.",
"message_moved_to": "Diese Nachricht wurde nach {folder} verschoben.",
"window_not_ready": "Das neue Fenster wurde nicht rechtzeitig geöffnet. Ihr Entwurf bleibt hier."
},
"desktop_sign_in": {
"browser_button": "Mit dem Browser anmelden",
"browser_waiting": "Schließen Sie die Anmeldung im Browser ab",
"browser_unavailable": "Dieser Server bietet keine Anmeldung über den Browser an. Verwenden Sie stattdessen Ihr Passwort.",
"browser_failed": "Die Anmeldung über den Browser hat nicht geklappt. Versuchen Sie es noch einmal.",
"server_needed": "Geben Sie zuerst die Adresse Ihres Servers ein.",
"server_declined": "Sie haben diesen Server nicht zugelassen, daher wurde nichts an ihn gesendet.",
"server_invalid": "Diese Serveradresse ist ungültig."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "This message was moved to another folder.",
"message_moved_to": "This message was moved to {folder}.",
"window_not_ready": "The new window did not open in time. Your draft stays here."
},
"desktop_sign_in": {
"browser_button": "Sign in with your browser",
"browser_waiting": "Finish signing in in your browser",
"browser_unavailable": "This server doesn't offer sign-in through a browser. Use your password instead.",
"browser_failed": "Signing in through your browser didn't work. Try again.",
"server_needed": "Enter your server's address first.",
"server_declined": "You didn't allow this server, so nothing was sent to it.",
"server_invalid": "This server address isn't valid."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Este mensaje se movió a otra carpeta.",
"message_moved_to": "Este mensaje se movió a {folder}.",
"window_not_ready": "La ventana nueva no se abrió a tiempo. Tu borrador se queda aquí."
},
"desktop_sign_in": {
"browser_button": "Iniciar sesión con el navegador",
"browser_waiting": "Termina de iniciar sesión en el navegador",
"browser_unavailable": "Este servidor no permite iniciar sesión con el navegador. Usa tu contraseña.",
"browser_failed": "No se pudo iniciar sesión con el navegador. Inténtalo de nuevo.",
"server_needed": "Primero escribe la dirección del servidor.",
"server_declined": "No permitiste este servidor, así que no se le envió nada.",
"server_invalid": "Esta dirección de servidor no es válida."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "See kiri teisaldati teise kausta.",
"message_moved_to": "See kiri teisaldati kausta {folder}.",
"window_not_ready": "Uus aken ei avanenud õigeks ajaks. Mustand jääb siia."
},
"desktop_sign_in": {
"browser_button": "Logi sisse brauseriga",
"browser_waiting": "Lõpeta sisselogimine brauseris",
"browser_unavailable": "See server ei paku brauseri kaudu sisselogimist. Kasuta parooli.",
"browser_failed": "Brauseri kaudu sisselogimine ei õnnestunud. Proovi uuesti.",
"server_needed": "Sisesta kõigepealt serveri aadress.",
"server_declined": "Sa ei lubanud seda serverit, nii et sellele ei saadetud midagi.",
"server_invalid": "See serveri aadress ei kehti."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "این پیام به پوشهٔ دیگری منتقل شد.",
"message_moved_to": "این پیام به {folder} منتقل شد.",
"window_not_ready": "پنجرهٔ جدید به‌موقع باز نشد. پیش‌نویس شما همین‌جا می‌ماند."
},
"desktop_sign_in": {
"browser_button": "ورود با مرورگر",
"browser_waiting": "ورود را در مرورگر کامل کنید",
"browser_unavailable": "این سرور ورود از طریق مرورگر را ارائه نمی‌دهد. از گذرواژه خود استفاده کنید.",
"browser_failed": "ورود از طریق مرورگر انجام نشد. دوباره تلاش کنید.",
"server_needed": "ابتدا نشانی سرور را وارد کنید.",
"server_declined": "شما این سرور را مجاز نکردید، پس چیزی به آن فرستاده نشد.",
"server_invalid": "این نشانی سرور معتبر نیست."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Ce message a été déplacé vers un autre dossier.",
"message_moved_to": "Ce message a été déplacé vers {folder}.",
"window_not_ready": "La nouvelle fenêtre ne s’est pas ouverte à temps. Votre brouillon reste ici."
},
"desktop_sign_in": {
"browser_button": "Se connecter avec le navigateur",
"browser_waiting": "Terminez la connexion dans votre navigateur",
"browser_unavailable": "Ce serveur ne propose pas la connexion par navigateur. Utilisez votre mot de passe.",
"browser_failed": "La connexion par navigateur a échoué. Réessayez.",
"server_needed": "Saisissez d'abord l'adresse du serveur.",
"server_declined": "Vous n'avez pas autorisé ce serveur, donc rien ne lui a été envoyé.",
"server_invalid": "Cette adresse de serveur n'est pas valide."
}
}
+9
View File
@@ -6501,5 +6501,14 @@
"message_moved": "ההודעה הזו הועברה לתיקייה אחרת.",
"message_moved_to": "ההודעה הזו הועברה אל {folder}.",
"window_not_ready": "החלון החדש לא נפתח בזמן. הטיוטה נשארת כאן."
},
"desktop_sign_in": {
"browser_button": "כניסה באמצעות הדפדפן",
"browser_waiting": "יש להשלים את הכניסה בדפדפן",
"browser_unavailable": "השרת הזה לא מאפשר כניסה דרך דפדפן. יש להשתמש בסיסמה.",
"browser_failed": "הכניסה דרך הדפדפן לא הצליחה. כדאי לנסות שוב.",
"server_needed": "קודם צריך להזין את כתובת השרת.",
"server_declined": "לא אישרת את השרת הזה, לכן לא נשלח אליו דבר.",
"server_invalid": "כתובת השרת הזו לא תקינה."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Ezt az üzenetet másik mappába helyezték át.",
"message_moved_to": "Ezt az üzenetet ide helyezték át: {folder}.",
"window_not_ready": "Az új ablak nem nyílt meg időben. A piszkozat itt marad."
},
"desktop_sign_in": {
"browser_button": "Bejelentkezés böngészővel",
"browser_waiting": "Fejezze be a bejelentkezést a böngészőben",
"browser_unavailable": "Ez a szerver nem kínál böngészős bejelentkezést. Használja a jelszavát.",
"browser_failed": "A böngészős bejelentkezés nem sikerült. Próbálja újra.",
"server_needed": "Először adja meg a szerver címét.",
"server_declined": "Nem engedélyezte ezt a szervert, ezért semmit sem küldtünk neki.",
"server_invalid": "Ez a szervercím nem érvényes."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Questo messaggio è stato spostato in un’altra cartella.",
"message_moved_to": "Questo messaggio è stato spostato in {folder}.",
"window_not_ready": "La nuova finestra non si è aperta in tempo. La bozza rimane qui."
},
"desktop_sign_in": {
"browser_button": "Accedi con il browser",
"browser_waiting": "Completa l'accesso nel browser",
"browser_unavailable": "Questo server non offre l'accesso tramite browser. Usa la password.",
"browser_failed": "L'accesso tramite browser non è riuscito. Riprova.",
"server_needed": "Inserisci prima l'indirizzo del server.",
"server_declined": "Non hai autorizzato questo server, quindi non gli è stato inviato nulla.",
"server_invalid": "Questo indirizzo del server non è valido."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "このメッセージは別のフォルダーに移動されました。",
"message_moved_to": "このメッセージは {folder} に移動されました。",
"window_not_ready": "新しいウィンドウが時間内に開きませんでした。下書きはここに残ります。"
},
"desktop_sign_in": {
"browser_button": "ブラウザでサインイン",
"browser_waiting": "ブラウザでサインインを完了してください",
"browser_unavailable": "このサーバーはブラウザでのサインインに対応していません。パスワードを使用してください。",
"browser_failed": "ブラウザでのサインインに失敗しました。もう一度お試しください。",
"server_needed": "先にサーバーのアドレスを入力してください。",
"server_declined": "このサーバーを許可しなかったため、何も送信していません。",
"server_invalid": "このサーバーアドレスは正しくありません。"
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "이 메시지는 다른 폴더로 이동되었습니다.",
"message_moved_to": "이 메시지는 {folder}(으)로 이동되었습니다.",
"window_not_ready": "새 창이 제때 열리지 않았습니다. 임시 보관 메일은 여기에 남아 있습니다."
},
"desktop_sign_in": {
"browser_button": "브라우저로 로그인",
"browser_waiting": "브라우저에서 로그인을 마치세요",
"browser_unavailable": "이 서버는 브라우저 로그인을 지원하지 않습니다. 비밀번호를 사용하세요.",
"browser_failed": "브라우저 로그인에 실패했습니다. 다시 시도하세요.",
"server_needed": "먼저 서버 주소를 입력하세요.",
"server_declined": "이 서버를 허용하지 않아 아무것도 보내지 않았습니다.",
"server_invalid": "이 서버 주소는 올바르지 않습니다."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Šis ziņojums tika pārvietots uz citu mapi.",
"message_moved_to": "Šis ziņojums tika pārvietots uz {folder}.",
"window_not_ready": "Jaunais logs neatvērās laikus. Melnraksts paliek šeit."
},
"desktop_sign_in": {
"browser_button": "Pierakstīties ar pārlūku",
"browser_waiting": "Pabeidziet pierakstīšanos pārlūkā",
"browser_unavailable": "Šis serveris nepiedāvā pierakstīšanos ar pārlūku. Izmantojiet paroli.",
"browser_failed": "Pierakstīšanās ar pārlūku neizdevās. Mēģiniet vēlreiz.",
"server_needed": "Vispirms ievadiet servera adresi.",
"server_declined": "Jūs neatļāvāt šo serveri, tāpēc tam nekas netika nosūtīts.",
"server_invalid": "Šī servera adrese nav derīga."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Энэ мессежийг өөр хавтас руу зөөсөн.",
"message_moved_to": "Энэ мессежийг {folder} руу зөөсөн.",
"window_not_ready": "Шинэ цонх цагтаа нээгдсэнгүй. Таны ноорог энд үлдэнэ."
},
"desktop_sign_in": {
"browser_button": "Хөтчөөр нэвтрэх",
"browser_waiting": "Нэвтрэлтээ хөтөч дээр дуусгана уу",
"browser_unavailable": "Энэ сервер хөтчөөр нэвтрэх боломжгүй. Нууц үгээ ашиглана уу.",
"browser_failed": "Хөтчөөр нэвтэрч чадсангүй. Дахин оролдоно уу.",
"server_needed": "Эхлээд серверийн хаягаа оруулна уу.",
"server_declined": "Та энэ серверийг зөвшөөрөөгүй тул юу ч илгээгээгүй.",
"server_invalid": "Энэ серверийн хаяг буруу байна."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Denne meldingen ble flyttet til en annen mappe.",
"message_moved_to": "Denne meldingen ble flyttet til {folder}.",
"window_not_ready": "Det nye vinduet åpnet seg ikke i tide. Utkastet blir her."
},
"desktop_sign_in": {
"browser_button": "Logg på med nettleseren",
"browser_waiting": "Fullfør påloggingen i nettleseren",
"browser_unavailable": "Denne serveren tilbyr ikke pålogging via nettleser. Bruk passordet ditt.",
"browser_failed": "Pålogging via nettleseren mislyktes. Prøv igjen.",
"server_needed": "Skriv inn serveradressen først.",
"server_declined": "Du tillot ikke denne serveren, så ingenting ble sendt til den.",
"server_invalid": "Denne serveradressen er ikke gyldig."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Dit bericht is naar een andere map verplaatst.",
"message_moved_to": "Dit bericht is verplaatst naar {folder}.",
"window_not_ready": "Het nieuwe venster is niet op tijd geopend. Je concept blijft hier."
},
"desktop_sign_in": {
"browser_button": "Inloggen met je browser",
"browser_waiting": "Rond het inloggen af in je browser",
"browser_unavailable": "Deze server biedt geen inloggen via een browser. Gebruik je wachtwoord.",
"browser_failed": "Inloggen via de browser is niet gelukt. Probeer het opnieuw.",
"server_needed": "Vul eerst het adres van de server in.",
"server_declined": "Je hebt deze server niet toegestaan, dus er is niets naartoe gestuurd.",
"server_invalid": "Dit serveradres is niet geldig."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Ta wiadomość została przeniesiona do innego folderu.",
"message_moved_to": "Ta wiadomość została przeniesiona do {folder}.",
"window_not_ready": "Nowe okno nie otworzyło się na czas. Twój szkic zostaje tutaj."
},
"desktop_sign_in": {
"browser_button": "Zaloguj się w przeglądarce",
"browser_waiting": "Dokończ logowanie w przeglądarce",
"browser_unavailable": "Ten serwer nie oferuje logowania przez przeglądarkę. Użyj hasła.",
"browser_failed": "Logowanie przez przeglądarkę nie powiodło się. Spróbuj ponownie.",
"server_needed": "Najpierw wpisz adres serwera.",
"server_declined": "Ten serwer nie został dozwolony, więc nic do niego nie wysłano.",
"server_invalid": "Ten adres serwera jest nieprawidłowy."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Esta mensagem foi movida para outra pasta.",
"message_moved_to": "Esta mensagem foi movida para {folder}.",
"window_not_ready": "A nova janela não abriu a tempo. O seu rascunho fica aqui."
},
"desktop_sign_in": {
"browser_button": "Entrar com o navegador",
"browser_waiting": "Termine de entrar no navegador",
"browser_unavailable": "Este servidor não oferece entrada pelo navegador. Use sua senha.",
"browser_failed": "Não foi possível entrar pelo navegador. Tente novamente.",
"server_needed": "Digite primeiro o endereço do servidor.",
"server_declined": "Você não permitiu este servidor, então nada foi enviado a ele.",
"server_invalid": "Este endereço de servidor não é válido."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Acest mesaj a fost mutat în alt dosar.",
"message_moved_to": "Acest mesaj a fost mutat în {folder}.",
"window_not_ready": "Fereastra nouă nu s-a deschis la timp. Ciorna rămâne aici."
},
"desktop_sign_in": {
"browser_button": "Conectează-te cu browserul",
"browser_waiting": "Finalizează conectarea în browser",
"browser_unavailable": "Acest server nu oferă conectare prin browser. Folosește parola.",
"browser_failed": "Conectarea prin browser nu a reușit. Încearcă din nou.",
"server_needed": "Introdu mai întâi adresa serverului.",
"server_declined": "Nu ai permis acest server, așa că nu i s-a trimis nimic.",
"server_invalid": "Această adresă de server nu este validă."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Это письмо перемещено в другую папку.",
"message_moved_to": "Это письмо перемещено в {folder}.",
"window_not_ready": "Новое окно не открылось вовремя. Черновик остаётся здесь."
},
"desktop_sign_in": {
"browser_button": "Войти через браузер",
"browser_waiting": "Завершите вход в браузере",
"browser_unavailable": "Этот сервер не поддерживает вход через браузер. Используйте пароль.",
"browser_failed": "Не удалось войти через браузер. Попробуйте ещё раз.",
"server_needed": "Сначала введите адрес сервера.",
"server_declined": "Вы не разрешили этот сервер, поэтому ему ничего не отправлено.",
"server_invalid": "Этот адрес сервера недействителен."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Táto správa bola presunutá do iného priečinka.",
"message_moved_to": "Táto správa bola presunutá do {folder}.",
"window_not_ready": "Nové okno sa neotvorilo včas. Koncept zostáva tu."
},
"desktop_sign_in": {
"browser_button": "Prihlásiť sa v prehliadači",
"browser_waiting": "Dokončite prihlásenie v prehliadači",
"browser_unavailable": "Tento server neponúka prihlásenie cez prehliadač. Použite heslo.",
"browser_failed": "Prihlásenie cez prehliadač sa nepodarilo. Skúste to znova.",
"server_needed": "Najprv zadajte adresu servera.",
"server_declined": "Tento server ste nepovolili, preto sa naň nič neodoslalo.",
"server_invalid": "Táto adresa servera nie je platná."
}
}
+9
View File
@@ -6508,5 +6508,14 @@
"message_moved": "ข้อความนี้ถูกย้ายไปยังโฟลเดอร์อื่น",
"message_moved_to": "ข้อความนี้ถูกย้ายไปยัง {folder}",
"window_not_ready": "หน้าต่างใหม่เปิดไม่ทัน แบบร่างของคุณยังอยู่ที่นี่"
},
"desktop_sign_in": {
"browser_button": "ลงชื่อเข้าใช้ด้วยเบราว์เซอร์",
"browser_waiting": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์",
"browser_unavailable": "เซิร์ฟเวอร์นี้ไม่รองรับการลงชื่อเข้าใช้ผ่านเบราว์เซอร์ โปรดใช้รหัสผ่าน",
"browser_failed": "ลงชื่อเข้าใช้ผ่านเบราว์เซอร์ไม่สำเร็จ โปรดลองอีกครั้ง",
"server_needed": "ป้อนที่อยู่เซิร์ฟเวอร์ก่อน",
"server_declined": "คุณไม่ได้อนุญาตเซิร์ฟเวอร์นี้ จึงไม่มีการส่งข้อมูลใดไป",
"server_invalid": "ที่อยู่เซิร์ฟเวอร์นี้ไม่ถูกต้อง"
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Bu ileti başka bir klasöre taşındı.",
"message_moved_to": "Bu ileti {folder} klasörüne taşındı.",
"window_not_ready": "Yeni pencere zamanında açılmadı. Taslağınız burada kalıyor."
},
"desktop_sign_in": {
"browser_button": "Tarayıcıyla oturum aç",
"browser_waiting": "Oturum açmayı tarayıcınızda tamamlayın",
"browser_unavailable": "Bu sunucu tarayıcıyla oturum açmayı desteklemiyor. Parolanızı kullanın.",
"browser_failed": "Tarayıcıyla oturum açılamadı. Yeniden deneyin.",
"server_needed": "Önce sunucu adresini girin.",
"server_declined": "Bu sunucuya izin vermediniz, bu yüzden ona hiçbir şey gönderilmedi.",
"server_invalid": "Bu sunucu adresi geçerli değil."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "Цього листа переміщено в іншу папку.",
"message_moved_to": "Цього листа переміщено в {folder}.",
"window_not_ready": "Нове вікно не відкрилося вчасно. Чернетка залишається тут."
},
"desktop_sign_in": {
"browser_button": "Увійти через браузер",
"browser_waiting": "Завершіть вхід у браузері",
"browser_unavailable": "Цей сервер не підтримує вхід через браузер. Використайте пароль.",
"browser_failed": "Не вдалося увійти через браузер. Спробуйте ще раз.",
"server_needed": "Спочатку введіть адресу сервера.",
"server_declined": "Ви не дозволили цей сервер, тому йому нічого не надіслано.",
"server_invalid": "Ця адреса сервера недійсна."
}
}
+9
View File
@@ -6512,5 +6512,14 @@
"message_moved": "Thư này đã được chuyển sang thư mục khác.",
"message_moved_to": "Thư này đã được chuyển đến {folder}.",
"window_not_ready": "Cửa sổ mới không mở kịp. Bản nháp của bạn vẫn ở đây."
},
"desktop_sign_in": {
"browser_button": "Đăng nhập bằng trình duyệt",
"browser_waiting": "Hoàn tất đăng nhập trong trình duyệt",
"browser_unavailable": "Máy chủ này không hỗ trợ đăng nhập qua trình duyệt. Hãy dùng mật khẩu.",
"browser_failed": "Không thể đăng nhập qua trình duyệt. Hãy thử lại.",
"server_needed": "Trước tiên hãy nhập địa chỉ máy chủ.",
"server_declined": "Bạn không cho phép máy chủ này nên không có gì được gửi đến đó.",
"server_invalid": "Địa chỉ máy chủ này không hợp lệ."
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "這封郵件已移到其他資料夾。",
"message_moved_to": "這封郵件已移到 {folder}。",
"window_not_ready": "新視窗未能及時開啟。你的草稿仍保留在這裡。"
},
"desktop_sign_in": {
"browser_button": "使用瀏覽器登入",
"browser_waiting": "請在瀏覽器中完成登入",
"browser_unavailable": "此伺服器不支援透過瀏覽器登入。請使用密碼。",
"browser_failed": "無法透過瀏覽器登入。請再試一次。",
"server_needed": "請先輸入伺服器位址。",
"server_declined": "你沒有允許此伺服器,因此沒有傳送任何內容給它。",
"server_invalid": "此伺服器位址無效。"
}
}
+9
View File
@@ -6500,5 +6500,14 @@
"message_moved": "这封邮件已移动到其他文件夹。",
"message_moved_to": "这封邮件已移动到 {folder}。",
"window_not_ready": "新窗口未能及时打开。你的草稿仍保留在这里。"
},
"desktop_sign_in": {
"browser_button": "使用浏览器登录",
"browser_waiting": "请在浏览器中完成登录",
"browser_unavailable": "此服务器不支持通过浏览器登录。请使用密码。",
"browser_failed": "无法通过浏览器登录。请重试。",
"server_needed": "请先输入服务器地址。",
"server_declined": "你没有允许此服务器,因此没有向它发送任何内容。",
"server_invalid": "此服务器地址无效。"
}
}
+4
View File
@@ -5,6 +5,7 @@ import { createHash } from "crypto";
import { readFileSync } from "fs";
import { join } from "path";
import { pruneTraces } from "./scripts/standalone-guard.mjs";
import { desktopBuildFlag } from "./scripts/desktop-build-flag.mjs";
// Prefer an explicit build arg (passed in by CI / Docker, where .git is
// excluded from the build context) and fall back to `git rev-parse` for
@@ -193,6 +194,9 @@ const nextConfig: NextConfig = {
NEXT_PUBLIC_BASE_PATH: basePath,
NEXT_PUBLIC_DEV_MOCK_JMAP: process.env.DEV_MOCK_JMAP ?? "",
NEXT_PUBLIC_BULWARK_LITE: isLite ? "1" : "",
// Always a literal, so setting the variable when starting a server build
// does nothing (lib/desktop/flag.ts).
NEXT_PUBLIC_BULWARK_DESKTOP: desktopBuildFlag(),
},
};
+1
View File
@@ -25,6 +25,7 @@
"dev": "node scripts/build-pst-worker.mjs && node scripts/build-store-worker.mjs && node scripts/build-plugin-sandbox.mjs --watch -- next dev --turbopack",
"build": "node scripts/build-pst-worker.mjs && node scripts/build-store-worker.mjs && node scripts/build-plugin-sandbox.mjs && next build --turbopack && node scripts/standalone-guard.mjs",
"build:lite": "node scripts/lite/build.mjs",
"build:desktop": "node scripts/build-desktop.mjs",
"lite:serve": "node scripts/lite/serve.mjs out 4173",
"test:lite-smoke": "playwright test -c scripts/lite/smoke/playwright.config.ts",
"test:lite-smoke:stalwart": "playwright test -c scripts/lite/smoke/playwright.stalwart.config.ts",
+6
View File
@@ -16,6 +16,8 @@ import { getWopiEditorOrigins } from "./lib/wopi/discovery";
import { detectSetupState } from "./lib/setup/state";
import { isServedOverHttps } from "./lib/security/request-protocol";
import { bootGateResponse } from "./lib/shared-store/boot-gate";
import { IS_DESKTOP_BUILD } from "./lib/desktop/flag";
import { isDesktopBlockedRoute } from "./lib/desktop/blocked-routes";
const intlMiddleware = createIntlMiddleware(routing);
@@ -268,6 +270,10 @@ export async function proxy(request: NextRequest) {
return NextResponse.json({ error: "Cross-origin request rejected" }, { status: 403 });
}
if (IS_DESKTOP_BUILD && isDesktopBlockedRoute(pathname)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (PROXY_SKIP_PATTERN.test(pathname) || isStaticAssetPath(pathname)) {
// No page CSP here, but what comes back can still be a document: the
// not-found shell for a missing /foo.html or an unknown /api route. It
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env node
// The webmail build the desktop app embeds: `npm run build:desktop`. It's the
// regular standalone build with NEXT_PUBLIC_BULWARK_DESKTOP compiled in, using
// webpack (a third of Turbopack's server size, and its hashed external links
// don't relocate). No other build sets the flag: repos/desktop's
// scripts/fetch-webmail.mjs refuses a tree without it.
import { spawnSync } from 'node:child_process';
const env = { ...process.env, NEXT_PUBLIC_BULWARK_DESKTOP: '1', NEXT_TELEMETRY_DISABLED: '1' };
const isWindows = process.platform === 'win32';
for (const [command, ...args] of [
['node', 'scripts/build-pst-worker.mjs'],
['node', 'scripts/build-store-worker.mjs'],
['node', 'scripts/build-plugin-sandbox.mjs'],
['npx', 'next', 'build', '--webpack'],
['node', 'scripts/standalone-guard.mjs'],
]) {
const run = spawnSync(command, args, { env, stdio: 'inherit', shell: isWindows });
if (run.status !== 0) process.exit(run.status ?? 1);
}
+19
View File
@@ -0,0 +1,19 @@
// The value next.config.ts inlines as NEXT_PUBLIC_BULWARK_DESKTOP.
//
// Always defined, so every build compiles `process.env.NEXT_PUBLIC_BULWARK_DESKTOP`
// to a literal. Left undefined, Next would leave the read in the server bundle,
// and setting the variable when starting a server build would turn the flag on.
/** The marker scripts/standalone-guard.mjs looks for in a non-desktop build. */
export const DESKTOP_TRUST_MARKER = 'bulwark.desktop.trustedJmapOrigins';
/**
* A read of the flag that survived compiling: then setting it at runtime would
* work. Only the read: Next also writes the bare name into server.js with the
* config's `env` block, as a JSON key with its compiled value.
*/
export const DESKTOP_FLAG_READ = /process\.env(\.NEXT_PUBLIC_BULWARK_DESKTOP\b|\[\s*["'`]NEXT_PUBLIC_BULWARK_DESKTOP["'`]\s*\])/;
export function desktopBuildFlag(env = process.env) {
return env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' ? '1' : '';
}
+28 -2
View File
@@ -18,6 +18,7 @@
import { existsSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { DESKTOP_FLAG_READ, DESKTOP_TRUST_MARKER, desktopBuildFlag } from './desktop-build-flag.mjs';
/** Top-level project entries the running server needs. Traces drop the rest. */
const TRACE_ALLOWED = new Set(['node_modules', '.next', 'public', 'locales', 'package.json', 'VERSION']);
@@ -141,10 +142,35 @@ export function removeCopiedEnvFiles(standaloneDir) {
return removed;
}
/**
* Server bundles that still hold the desktop app's trust path. A server build
* compiles it away (lib/desktop/flag.ts); finding it means the flag leaked into
* this build or stopped being inlined, and a visitor could reach private hosts.
*/
export function findDesktopTrustPath(standaloneDir) {
const found = [];
const visit = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name !== 'node_modules') visit(full);
} else if (entry.name.endsWith('.js')) {
const text = readFileSync(full, 'utf8');
if (text.includes(DESKTOP_TRUST_MARKER) || DESKTOP_FLAG_READ.test(text)) {
found.push(`${toPosix(path.relative(standaloneDir, full))} (desktop trust path in a server build)`);
}
}
}
};
visit(standaloneDir);
return found.sort();
}
/** Cleans and checks a standalone tree. Returns what is still wrong with it. */
export function guardStandalone(standaloneDir) {
export function guardStandalone(standaloneDir, { desktopBuild = desktopBuildFlag() === '1' } = {}) {
removeCopiedEnvFiles(standaloneDir);
return findForbidden(standaloneDir);
const forbidden = findForbidden(standaloneDir);
return desktopBuild ? forbidden : [...forbidden, ...findDesktopTrustPath(standaloneDir)];
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+15
View File
@@ -1,9 +1,19 @@
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { mainWindowStorage } from '@/lib/popout/window-role';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { forgetDesktopServer } from '@/lib/desktop/bridge';
import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils';
import { authHooks } from '@/lib/plugin-hooks';
function sameServerOrigin(a: string, b: string): boolean {
try {
return new URL(a).origin === new URL(b).origin;
} catch {
return a === b;
}
}
export interface AccountEntry {
/** Unique key: `${username}@${serverHostname}` */
id: string;
@@ -150,6 +160,7 @@ export const useAccountStore = create<AccountState>()(
removeAccount: (accountId) => {
if (!get().accounts.some((a) => a.id === accountId)) return;
const leaving = get().accounts.find((a) => a.id === accountId);
set((s) => {
const remaining = s.accounts.filter((a) => a.id !== accountId);
const wasDefault = s.defaultAccountId === accountId;
@@ -174,6 +185,10 @@ export const useAccountStore = create<AccountState>()(
};
});
void authHooks.onAccountRemove.emit({ accountId });
// The desktop app stops trusting a server its last account left.
if (IS_DESKTOP_BUILD && leaving && !get().accounts.some((a) => sameServerOrigin(a.serverUrl, leaving.serverUrl))) {
forgetDesktopServer(leaving.serverUrl, 'signed-out');
}
},
setActiveAccount: (accountId) => {
+13
View File
@@ -16,6 +16,9 @@ import { debug } from '@/lib/debug';
import { setServerAuthIssue } from '@/lib/server-auth-status';
import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in';
import { isEmbedded, notifyParent } from '@/lib/iframe-bridge';
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager';
import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope';
@@ -479,6 +482,8 @@ async function exchangeOAuthCode(params: {
slot,
...(serverId ? { server_id: serverId } : {}),
remember,
// The desktop app signs in to a server only it knows about.
...(IS_DESKTOP_BUILD ? { custom_server_url: serverUrl, client_id: sessionStorage.getItem(DESKTOP_CLIENT_ID_KEY) } : {}),
}),
}).then((res) => noteSessionAnswer(res, slot));
}
@@ -1049,6 +1054,14 @@ function finishSignOut(endSessionUrl: string | null, userInitiated: boolean): vo
}
}
if (endSessionUrl) {
// The desktop build signs in through the system browser, so the provider's
// session lives there. The window itself goes straight to the login page.
const desktopShell = IS_DESKTOP_BUILD ? getDesktopBridge()?.shell : undefined;
if (desktopShell) {
desktopShell.openExternal(endSessionUrl);
navigateToLogin();
return;
}
leavingForProviderLogout = true;
replaceWindowLocation(endSessionUrl);
return;