diff --git a/app/(main)/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx index 97bace353..e3862393a 100644 --- a/app/(main)/[locale]/auth/callback/page.tsx +++ b/app/(main)/[locale]/auth/callback/page.tsx @@ -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'); diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 764ae5547..992bef2ec 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -34,6 +34,9 @@ import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt import { SessionTimeoutGuard } from "@/components/session/session-timeout-guard"; import { AccountTemplateSync } from "@/components/templates/account-template-sync"; import { FreeBusyAutoShare } from "@/components/calendar/free-busy-auto-share"; +import { MainWindowOnly } from "@/components/popout/main-window-only"; +import { PopoutHost } from "@/components/popout/popout-host"; +import { WindowSyncHost } from "@/components/popout/window-sync-host"; import { IS_LITE } from "@/lib/lite"; import { setRequestLocale } from "next-intl/server"; import { locales } from "@/i18n/routing"; @@ -79,16 +82,22 @@ export default async function LocaleLayout({ - - + + + + + {!IS_LITE && } + + {!IS_LITE && } - {children} - + + + @@ -99,16 +108,20 @@ export default async function LocaleLayout({ - - - + + + + + {/* Offline mode isn't in Lite (no service worker there). */} {!IS_LITE && } {!IS_LITE && } - - {/* Lite is a static bundle: no server keeps an archive there. */} - {!IS_LITE && } + + + {/* Lite is a static bundle: no server keeps an archive there. */} + {!IS_LITE && } + diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 1ef9932e0..bf9b69c44 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -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(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() && ( + + )} + {IS_DESKTOP_BUILD && desktopNotice && ( +

+ {desktopNotice} +

+ )} {((serverOauthEnabled && oauthDiscoveryDone && !oauthMetadata) || liteOAuthFailed) && (
diff --git a/app/(main)/[locale]/popout/compose/page.tsx b/app/(main)/[locale]/popout/compose/page.tsx new file mode 100644 index 000000000..88ab7d524 --- /dev/null +++ b/app/(main)/[locale]/popout/compose/page.tsx @@ -0,0 +1,7 @@ +import { PopoutComposeApp } from "@/components/popout/popout-compose-app"; + +// The composer in a window of its own (lib/popout). Not in Lite: scripts/lite +// removes this tree, and "Open in new window" isn't offered there. +export default function PopoutComposePage() { + return ; +} diff --git a/app/(main)/[locale]/popout/message/[id]/page.tsx b/app/(main)/[locale]/popout/message/[id]/page.tsx new file mode 100644 index 000000000..806370780 --- /dev/null +++ b/app/(main)/[locale]/popout/message/[id]/page.tsx @@ -0,0 +1,8 @@ +import { PopoutMessageApp } from "@/components/popout/popout-message-app"; + +// One message in a window of its own (lib/popout). Not in Lite: scripts/lite +// removes this tree, since a static export can't list every message id. +export default async function PopoutMessagePage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx index a1015bd64..127deb308 100644 --- a/app/(main)/[locale]/pro/page.tsx +++ b/app/(main)/[locale]/pro/page.tsx @@ -509,7 +509,7 @@ export default function ProHome() { return ( {/* data-pro-shell: a page skeleton inside a pane draws its own frame, not the Pro one. */} -
+
{/* Once for the whole shell: email and folder tabs have no app strip, and split panes would show it twice. */}
diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index d186b72da..e2704bcc8 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -20,8 +20,11 @@ import { locales, defaultLocale } from "@/i18n/routing"; import { IS_LITE, LITE_MOUNT_GLOBAL } from "@/lib/lite"; import { accessibilityBootScript } from "@/lib/accessibility-settings"; import { bootSkeletonScript } from "@/lib/boot-skeleton/boot-script"; +import { WindowTitleBar } from "@/components/layout/window-title-bar"; import "../globals.css"; import "../print.css"; +import "../desktop.css"; +import { desktopBootScript } from "@/lib/desktop/boot"; // This layout renders and sits ABOVE the [locale] segment, so // next-intl's getLocale() returns the default locale here - emitting @@ -308,6 +311,7 @@ export default async function RootLayout({ dangerouslySetInnerHTML={{ __html: ` (function() {${IS_LITE ? liteLocaleBootstrap() : ""} + ${desktopBootScript()} try { const stored = localStorage.getItem('theme-storage'); const theme = stored ? JSON.parse(stored).state.theme : 'system'; @@ -330,6 +334,7 @@ export default async function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} ${hankenGrotesk.variable} antialiased`} > + {!themeColorConfigured && } diff --git a/app/api/auth/desktop-oauth/route.ts b/app/api/auth/desktop-oauth/route.ts new file mode 100644 index 000000000..c6fa99102 --- /dev/null +++ b/app/api/auth/desktop-oauth/route.ts @@ -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 { + 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 }); + } +} diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 1afa1ab96..32b044ac2 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -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( diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts index c2afc77b6..516f5ac11 100644 --- a/app/api/auth/stalwart-context/route.ts +++ b/app/api/auth/stalwart-context/route.ts @@ -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( diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index 59b5834c7..ba2d3d9ee 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -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 { +async function revokeRefreshToken(token: string, serverId: string | null, metadata: OAuthMetadata | null, upstream: CustomUpstream | null): Promise { 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) { @@ -260,13 +276,14 @@ export async function PUT(request: NextRequest) { // Discovery or the token endpoint out of reach is an outage: the client // waits it out. A 500 here would count toward signing the user out. let tokenResponse: Response; + const upstream = IS_DESKTOP_BUILD ? await readCustomUpstream(cookieStore, slot) : null; try { - const tokenEndpoint = await getTokenEndpoint(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }); + 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)); tokenResponse = await observeUpstream('oauth', () => fetch(tokenEndpoint, { method: 'POST', @@ -291,8 +308,12 @@ 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 }); + // Only invalid_grant says the login itself is over. invalid_client, or + // a provider refusing an early refresh (#552), says nothing about it, + // and the client keeps the account's local data for those. + return NextResponse.json({ error: oauthErrorCode(errorText) === 'invalid_grant' ? 'invalid_grant' : 'Refresh failed' }, { status: 401 }); } return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 }); } @@ -332,10 +353,23 @@ 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 }); } } +function oauthErrorCode(body: string): string | null { + try { + const code = (JSON.parse(body) as { error?: unknown }).error; + return typeof code === 'string' ? code : null; + } catch { + return null; + } +} + /** * Revoke a slot's refresh token and clear its token cookies. With * `endSession`, also returns the URL that ends the provider's session, or null @@ -351,19 +385,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 @@ -374,6 +409,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; } diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index 4c374a4f3..a16be7089 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -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 { 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, diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts index 028d6432a..dd404e9a3 100644 --- a/app/api/auth/verify/route.ts +++ b/app/api/auth/verify/route.ts @@ -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); diff --git a/app/desktop.css b/app/desktop.css new file mode 100644 index 000000000..7f1b55c74 --- /dev/null +++ b/app/desktop.css @@ -0,0 +1,76 @@ +/* + * The desktop app is an app, not a page. Everything here hangs on + * html[data-desktop], which the boot script sets when the preload bridge is + * there (lib/desktop/boot.ts), so a browser never matches any of it. + * + * UI chrome is listed one by one, instead of switching selection off for the + * whole page and listing what may stay on: a message, a contact's details or + * an event's notes must not stop being selectable because nobody thought of + * them. An HTML message is in its own frame; a plain-text one is in the page, + * and the rule at the end keeps it selectable wherever it sits. + */ + +html[data-desktop], +html[data-desktop] body { + overscroll-behavior: none; +} + +html[data-desktop] :is( + button, + summary, + label, + nav, + [role="button"], + [role="navigation"], + [role="toolbar"], + [role="menu"], + [role="menubar"], + [role="menuitem"], + [role="menuitemcheckbox"], + [role="menuitemradio"], + [role="tab"], + [role="tablist"], + [role="listbox"], + [role="option"], + [role="tree"], + [role="treeitem"], + [role="row"], + [data-composer-header], + [data-mv-compose-titlebar], + [data-window-title-bar] +) { + -webkit-user-select: none; + user-select: none; + cursor: default; +} + +/* A field inside a label or a row is still a field. */ +html[data-desktop] :is(input, textarea, select, [contenteditable="true"], [contenteditable="plaintext-only"]) { + -webkit-user-select: text; + user-select: text; +} + +html[data-desktop] :is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="button"], [type="submit"]), textarea, [contenteditable="true"], [contenteditable="plaintext-only"]) { + cursor: text; +} + +/* No dragging an icon or a link by accident. What the app means to drag says so. */ +html[data-desktop] :is(img, a, svg) { + -webkit-user-drag: none; +} + +html[data-desktop] [draggable="true"] { + -webkit-user-drag: element; +} + +/* A plain-text message renders in the page, also inside rows and buttons. */ +html[data-desktop] [data-plugin-source="email-body"], +html[data-desktop] [data-plugin-source="email-body"] * { + -webkit-user-select: text; + user-select: text; + cursor: text; +} + +html[data-desktop] [data-plugin-source="email-body"] a { + cursor: pointer; +} diff --git a/app/globals.css b/app/globals.css index 7cbfd50c8..212b080c2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -568,6 +568,25 @@ optgroup { /* Mobile Responsive Utilities */ +/* + * The top of the page in an installed app without a native title bar. 0 + * without one: then --app-top-inset is exactly env(safe-area-inset-top), what + * the page roots used before. env() and not the display-mode media query, + * which is false in Electron with titleBarOverlay. Set at the root, so it + * holds from the first paint, for the boot skeletons too; + * lib/window-controls-overlay.ts keeps it up to date afterwards. + */ +:root { + --titlebar-inset: env(titlebar-area-height, 0px); + --app-top-inset: max(env(safe-area-inset-top), var(--titlebar-inset)); +} + +/* components/layout/window-title-bar.tsx: what the window is dragged by. */ +.window-title-bar { + app-region: drag; + -webkit-app-region: drag; +} + /* Safe area insets for notched devices (iPhone X+, etc.) */ .safe-area-inset-top { padding-top: env(safe-area-inset-top); diff --git a/app/manifest.ts b/app/manifest.ts index 53376bc12..c95478c26 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -35,7 +35,8 @@ type WebAppShareTarget = { }; }; -type ExtendedManifest = MetadataRoute.Manifest & { +type ExtendedManifest = Omit & { + display_override?: Array<"window-controls-overlay" | "standalone" | "minimal-ui" | "browser" | "fullscreen">; protocol_handlers?: WebAppProtocolHandler[]; file_handlers?: WebAppFileHandler[]; share_target?: WebAppShareTarget; @@ -124,6 +125,9 @@ export default async function manifest(): Promise { start_url: withBase("/"), scope: withBase("/"), display: "standalone", + // The page draws up to the top of the window, next to the native controls + // (components/layout/window-title-bar.tsx). `display` is the fallback. + display_override: ["window-controls-overlay", "standalone"], orientation: "portrait-primary", theme_color: themeColor, background_color: backgroundColor, diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx index 5343afe10..857ab630b 100644 --- a/components/admin/admin-shell.tsx +++ b/components/admin/admin-shell.tsx @@ -263,7 +263,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, const backHref = location.detail ? adminPath(location.page) : '/admin'; return ( -
+
{showIndex ? ( -
+
{/* The same rail as mail and settings, with Admin as the current app. */}
diff --git a/components/boot-skeleton/admin-skeleton.tsx b/components/boot-skeleton/admin-skeleton.tsx index 5eeeeebaf..16bc29d97 100644 --- a/components/boot-skeleton/admin-skeleton.tsx +++ b/components/boot-skeleton/admin-skeleton.tsx @@ -171,7 +171,7 @@ export function AdminSkeleton({ label, page, backLabel, searchLabel, labels, cla {/* Desktop */}
diff --git a/components/boot-skeleton/pro-skeleton.tsx b/components/boot-skeleton/pro-skeleton.tsx index a5294ee56..3e6eecda0 100644 --- a/components/boot-skeleton/pro-skeleton.tsx +++ b/components/boot-skeleton/pro-skeleton.tsx @@ -109,7 +109,7 @@ export function ProFrameSkeleton({
diff --git a/components/boot-skeleton/shell.tsx b/components/boot-skeleton/shell.tsx index ced5c7b32..15f66019d 100644 --- a/components/boot-skeleton/shell.tsx +++ b/components/boot-skeleton/shell.tsx @@ -294,7 +294,7 @@ export function AppShellSkeleton({
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 05cee1219..291c715b6 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -2163,7 +2163,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = ); return ( -
+
{!isEmbedded && {tShell("skip_to_content")}} {refreshIndicator} @@ -2221,7 +2221,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = "border-e border-border bg-secondary flex flex-col overflow-hidden flex-shrink-0", !isResizing && "transition-[width] duration-300", isNarrow && cn( - "absolute inset-y-0 start-0 z-50 w-72 pt-[env(safe-area-inset-top)]", + "absolute inset-y-0 start-0 z-50 w-72 pt-[var(--app-top-inset)]", "transform transition-transform duration-300 ease-in-out", !narrowSidebarOpen && "-translate-x-full rtl:translate-x-full" ) @@ -2556,7 +2556,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = role="dialog" aria-modal="true" aria-label={editTask ? t("tasks.edit") : t("tasks.create")} - className={isPaneScoped ? "absolute inset-0 z-40 flex flex-col bg-background" : "fixed inset-0 z-50 flex flex-col bg-background"} + className={isPaneScoped ? "absolute inset-0 z-40 flex flex-col bg-background" : "fixed inset-0 z-50 flex flex-col bg-background pt-[var(--titlebar-inset)]"} > (isEdit && !startInEditMode ? "view" : "edit"); diff --git a/components/command-palette/command-palette.tsx b/components/command-palette/command-palette.tsx index dee815fb7..058d4a448 100644 --- a/components/command-palette/command-palette.tsx +++ b/components/command-palette/command-palette.tsx @@ -326,7 +326,7 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS aria-label={t("title")} className={cn( "w-full max-w-xl overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl", - "max-sm:h-full max-sm:max-w-none max-sm:rounded-none max-sm:border-0 max-sm:pt-[env(safe-area-inset-top)]", + "max-sm:h-full max-sm:max-w-none max-sm:rounded-none max-sm:border-0 max-sm:pt-[var(--app-top-inset)]", "flex flex-col", )} > diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 0f9da0954..fcf3b0afd 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -1209,7 +1209,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = }; return ( -
+
{!isEmbedded && {tShell("skip_to_content")}} {refreshIndicator} @@ -1265,7 +1265,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = "border-e border-border flex flex-col flex-shrink-0 bg-background", !isSidebarResizing && "transition-[width] duration-300", isNarrow && cn( - "absolute inset-y-0 start-0 z-50 w-72 pt-[env(safe-area-inset-top)]", + "absolute inset-y-0 start-0 z-50 w-72 pt-[var(--app-top-inset)]", "transform transition-[transform,visibility] duration-300 ease-in-out", !narrowSidebarOpen && "invisible -translate-x-full rtl:translate-x-full" ) diff --git a/components/email/__tests__/composer-own-window.test.tsx b/components/email/__tests__/composer-own-window.test.tsx new file mode 100644 index 000000000..03f84e9b5 --- /dev/null +++ b/components/email/__tests__/composer-own-window.test.tsx @@ -0,0 +1,364 @@ +import React from 'react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextIntlClientProvider } from 'next-intl'; +import { EmailComposer, type ComposerDraftData, type ComposerPopOut } from '../email-composer'; +import { JMAPClient } from '@/lib/jmap/client'; +import { useAuthStore } from '@/stores/auth-store'; +import { useIdentityStore } from '@/stores/identity-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; +import en from '@/locales/en/common.json'; +import { emailHooks } from '@/lib/plugin-hooks'; + +// Moving a draft to its own window, and a composer that is all its window +// shows. Real composer, stores and JMAP draft payloads over a synthetic server. +vi.unmock('next-intl'); +vi.mock('@/components/email/rich-text-editor', () => ({ RichTextEditor: () => null })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +type Props = React.ComponentProps; +type Draft = { subject: string }; + +const original = { auth: useAuthStore.getState(), identity: useIdentityStore.getState(), settings: useSettingsStore.getState() }; +let client: JMAPClient; +let creations: Draft[]; +let rejectSave: boolean; +let deferSave: boolean; +let finishSave: (() => void) | undefined; + +beforeEach(async () => { + creations = []; rejectSave = false; deferSave = false; finishSave = undefined; + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ + capabilities: { 'urn:ietf:params:jmap:core': {} }, + accounts: { owner: { name: 'me@example.test', isPersonal: true, accountCapabilities: {} } }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'owner' }, + apiUrl: 'https://synthetic.example.test/api', uploadUrl: 'https://synthetic.example.test/upload/{accountId}', + downloadUrl: 'https://synthetic.example.test/download/{accountId}/{blobId}/{name}', eventSourceUrl: '', + })))); + client = new JMAPClient('https://synthetic.example.test', 'me@example.test', 'synthetic-password'); + await client.connect(); + vi.spyOn(client, 'ping').mockResolvedValue(undefined); + vi.mocked(fetch).mockImplementation(async (_url, init) => { + const { methodCalls } = JSON.parse(String(init?.body)) as { + methodCalls: Array<[string, { create?: Record; ids?: string[]; destroy?: string[] }, string]>; + }; + const methodResponses = []; + for (const [method, args, id] of methodCalls) { + if (method === 'Mailbox/get') methodResponses.push([method, { list: [{ id: 'drafts', role: 'drafts' }] }, id]); + else if (method === 'Email/set' && args.create) { + const created: Record = {}; + for (const [key, draft] of Object.entries(args.create)) { + creations.push(draft); + if (deferSave) await new Promise((resolve) => { finishSave = resolve; }); + created[key] = { id: `saved-${creations.length}` }; + } + methodResponses.push([method, rejectSave + ? { notCreated: { draft: { type: 'serverFail', description: 'Synthetic refusal' } } } + : { created }, id]); + } else if (method === 'Email/get') methodResponses.push([method, { list: (args.ids ?? []).map((draftId) => ({ id: draftId, attachments: [] })) }, id]); + else if (method === 'Email/set') methodResponses.push([method, { destroyed: args.destroy ?? [] }, id]); + else methodResponses.push([method, {}, id]); + } + return new Response(JSON.stringify({ methodResponses })); + }); + useAuthStore.setState({ client, activeAccountId: 'login', isAuthenticated: true, isLoading: false, + getClientForAccount: (id) => id === 'login' ? client : undefined, + getAllConnectedClients: () => new Map([['login', client]]), + }); + useIdentityStore.setState({ identities: [{ id: 'me', email: 'me@example.test', name: 'Me', mayDelete: false } as never] }); + useSettingsStore.setState({ defaultSendDelay: 0 } as never); + useToastStore.getState().clearToasts(); +}); + +afterEach(() => { + cleanup(); + client.disconnect(); + useAuthStore.setState(original.auth); + useIdentityStore.setState(original.identity); + useSettingsStore.setState(original.settings); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function mount(props: Partial) { + return render( + + + , + ); +} + +const subjectField = () => screen.getByPlaceholderText(en.email_composer.subject_placeholder); + +function target(shown = true): ComposerPopOut & { handed: ComposerDraftData[]; cancelled: number } { + const handed: ComposerDraftData[] = []; + const window = { + handed, + cancelled: 0, + handOver: async (data: ComposerDraftData) => { handed.push(data); return shown; }, + cancel: () => { window.cancelled += 1; }, + }; + return window; +} + +const DRAFT: ComposerDraftData = { to: '', cc: '', bcc: '', subject: 'Old', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: null }; + +describe('"Open in new window" on a composer', () => { + it('is not there without the host offering it: browsers stay as they are', () => { + mount({}); + expect(screen.queryByTestId('composer-open-in-window')).toBeNull(); + }); + + it('opens the window inside the click, before any save', async () => { + const popOut = vi.fn(() => target()); + mount({ popOut }); + fireEvent.click(screen.getByTestId('composer-open-in-window')); + // Synchronously: a browser only opens a window for a user action. + expect(popOut).toHaveBeenCalledTimes(1); + await waitFor(() => expect(popOut.mock.results[0].value.handed).toHaveLength(1)); + }); + + it('saves the text, hands over the same draft id, and closes here', async () => { + const window = target(); + const onClose = vi.fn(); + const onSaveState = vi.fn(); + mount({ popOut: () => window, onClose, onSaveState }); + fireEvent.change(subjectField(), { target: { value: 'Moves along' } }); + fireEvent.click(screen.getByTestId('composer-open-in-window')); + await waitFor(() => expect(window.handed).toHaveLength(1)); + const data = window.handed[0]; + expect(data.subject).toBe('Moves along'); + expect(creations.map((draft) => draft.subject)).toEqual(['Moves along']); + expect(data.draftId).toBe('saved-1'); + expect(data.unsaved).toBe(false); + expect(onClose).toHaveBeenCalledTimes(1); + // Not left behind in the host's "continue draft" slot. + cleanup(); + expect(onSaveState).not.toHaveBeenCalled(); + }); + + it('keeps a draft that is already saved under its id, without saving again', async () => { + const window = target(); + mount({ + popOut: () => window, + initialData: { to: '', cc: '', bcc: '', subject: 'Old', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: 'existing' }, + }); + fireEvent.click(screen.getByTestId('composer-open-in-window')); + await waitFor(() => expect(window.handed).toHaveLength(1)); + expect(window.handed[0].draftId).toBe('existing'); + expect(creations).toEqual([]); + }); + + it('keeps the composer and calls it off when the other window never shows it', async () => { + const window = target(false); + const onClose = vi.fn(); + mount({ popOut: () => window, onClose }); + fireEvent.change(subjectField(), { target: { value: 'Still here' } }); + fireEvent.click(screen.getByTestId('composer-open-in-window')); + await waitFor(() => expect(window.cancelled).toBe(1)); + expect(onClose).not.toHaveBeenCalled(); + expect((subjectField() as HTMLInputElement).value).toBe('Still here'); + expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain(en.pwaWindows.window_not_ready); + }); + + it('does not close here before the other window shows it, also after a failed save', async () => { + rejectSave = true; + let show: (shown: boolean) => void = () => {}; + const handed: ComposerDraftData[] = []; + const onClose = vi.fn(); + mount({ + popOut: () => ({ cancel: vi.fn(), handOver: (data) => { handed.push(data); return new Promise((resolve) => { show = resolve; }); } }), + onClose, + }); + fireEvent.change(subjectField(), { target: { value: 'Refused' } }); + fireEvent.click(screen.getByTestId('composer-open-in-window')); + await waitFor(() => expect(handed).toHaveLength(1)); + expect(handed[0].unsaved).toBe(true); + expect(onClose).not.toHaveBeenCalled(); + show(true); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); + + it('takes the send options along', async () => { + const window = target(); + mount({ popOut: () => window, initialData: { ...DRAFT, requestReadReceipt: true, requestDsn: true, requireTls: true } }); + fireEvent.click(screen.getByTestId('composer-open-in-window')); + await waitFor(() => expect(window.handed).toHaveLength(1)); + expect(window.handed[0]).toMatchObject({ requestReadReceipt: true, requestDsn: true, requireTls: true }); + }); + + it('stays open and says why when the browser blocks the window', async () => { + const onClose = vi.fn(); + mount({ popOut: () => null, onClose }); + fireEvent.change(subjectField(), { target: { value: 'Stays' } }); + fireEvent.click(screen.getByTestId('composer-open-in-window')); + await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain(en.pwaWindows.popup_blocked)); + expect(onClose).not.toHaveBeenCalled(); + expect((subjectField() as HTMLInputElement).value).toBe('Stays'); + }); +}); + +describe('a composer that is all its window shows', () => { + function unload() { + const event = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(event); + return event; + } + + it('asks before the window closes with unsaved text, and saves meanwhile', async () => { + const onUnloadState = vi.fn(); + mount({ ownWindow: { onUnloadState } }); + fireEvent.change(subjectField(), { target: { value: 'Not saved yet' } }); + const event = unload(); + expect(event.defaultPrevented).toBe(true); + expect(onUnloadState.mock.calls[0][0]).toMatchObject({ subject: 'Not saved yet', unsaved: true }); + await waitFor(() => expect(creations.map((draft) => draft.subject)).toEqual(['Not saved yet'])); + }); + + it('hands unsaved text to another window as it closes, with no prompt and no save of its own', () => { + const rescueDraft = vi.fn(() => true); + mount({ ownWindow: { rescueDraft } }); + fireEvent.change(subjectField(), { target: { value: 'Rescued' } }); + expect(unload().defaultPrevented).toBe(false); + expect(rescueDraft).toHaveBeenCalledTimes(1); + expect(rescueDraft.mock.calls[0]).toEqual([expect.objectContaining({ subject: 'Rescued', draftId: undefined })]); + expect(creations).toEqual([]); + }); + + it('replaces the version it holds when another window saves it', () => { + const rescueDraft = vi.fn(() => true); + mount({ + ownWindow: { rescueDraft }, + initialData: { to: '', cc: '', bcc: '', subject: 'Old', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: 'saved-before' }, + }); + fireEvent.change(subjectField(), { target: { value: 'New' } }); + unload(); + expect(rescueDraft.mock.calls[0]).toEqual([expect.objectContaining({ subject: 'New', draftId: 'saved-before' })]); + }); + + it('asks and saves itself when no other window can take it', async () => { + const rescueDraft = vi.fn(() => false); + mount({ ownWindow: { rescueDraft } }); + fireEvent.change(subjectField(), { target: { value: 'Nobody home' } }); + expect(unload().defaultPrevented).toBe(true); + await waitFor(() => expect(creations.map((draft) => draft.subject)).toEqual(['Nobody home'])); + }); + + it('does not hand it over after a failed save: that window would likely fail too, with nobody looking', async () => { + rejectSave = true; + const rescueDraft = vi.fn(() => true); + mount({ ownWindow: { rescueDraft } }); + fireEvent.change(subjectField(), { target: { value: 'Server down' } }); + await waitFor(() => expect(creations).toHaveLength(1), { timeout: 4000 }); + await waitFor(() => expect(unload().defaultPrevented).toBe(true)); + expect(rescueDraft).not.toHaveBeenCalled(); + }); + + it('does not hand it over while the browser is offline', () => { + const onLine = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false); + const rescueDraft = vi.fn(() => true); + mount({ ownWindow: { rescueDraft } }); + fireEvent.change(subjectField(), { target: { value: 'Wi-Fi gone' } }); + expect(unload().defaultPrevented).toBe(true); + expect(rescueDraft).not.toHaveBeenCalled(); + onLine.mockRestore(); + }); + + it('does not hand it over with a save running: two saves would make two drafts', async () => { + deferSave = true; + const rescueDraft = vi.fn(() => true); + mount({ ownWindow: { rescueDraft } }); + fireEvent.change(subjectField(), { target: { value: 'Saving' } }); + await waitFor(() => expect(creations).toHaveLength(1), { timeout: 4000 }); + expect(unload().defaultPrevented).toBe(true); + expect(rescueDraft).not.toHaveBeenCalled(); + finishSave?.(); + }); + + it('asks when the window closes during a send', async () => { + let finish: () => void = () => {}; + const onSend = vi.fn(() => new Promise((resolve) => { finish = resolve; })); + const onClose = vi.fn(); + mount({ + ownWindow: {}, onSend, onClose, + initialData: { ...DRAFT, to: 'them@example.test', subject: 'Going', body: '

Hello

' }, + }); + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + await waitFor(() => expect(onSend).toHaveBeenCalled(), { timeout: 4000 }); + expect(unload().defaultPrevented).toBe(true); + finish(); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(unload().defaultPrevented).toBe(false); + }); + + it('counts text handed in unsaved as unsaved: it asks, and saves it', async () => { + const rescueDraft = vi.fn(() => false); + mount({ ownWindow: { rescueDraft }, initialData: { ...DRAFT, subject: 'Handed in', unsaved: true } }); + expect(unload().defaultPrevented).toBe(true); + await waitFor(() => expect(creations.map((draft) => draft.subject)).toEqual(['Handed in'])); + }); + + it('saves text handed in unsaved by itself, untouched', async () => { + mount({ ownWindow: {}, initialData: { ...DRAFT, subject: 'Autosaved after the move', unsaved: true } }); + await waitFor(() => expect(creations.map((draft) => draft.subject)).toEqual(['Autosaved after the move']), { timeout: 4000 }); + }); + + it('keeps the draft to itself when a plugin has a say in what is saved', async () => { + const rescueDraft = vi.fn(() => true); + const hook = emailHooks.onBeforeDraftAutoSave.register('test', (draft: unknown) => draft as never); + try { + mount({ ownWindow: { rescueDraft } }); + fireEvent.change(subjectField(), { target: { value: 'Encrypted, say' } }); + expect(unload().defaultPrevented).toBe(true); + expect(rescueDraft).not.toHaveBeenCalled(); + } finally { + hook.dispose(); + } + }); + + it('lets an untouched composer go without a word', () => { + mount({ ownWindow: {} }); + expect(unload().defaultPrevented).toBe(false); + expect(creations).toEqual([]); + }); + + it('does not ask again, or save again, once the autosave has it', async () => { + const onDraftSaved = vi.fn(); + mount({ ownWindow: { onDraftSaved } }); + fireEvent.change(subjectField(), { target: { value: 'Autosaved' } }); + await waitFor(() => expect(creations).toHaveLength(1), { timeout: 4000 }); + await waitFor(() => expect(onDraftSaved).toHaveBeenCalled()); + expect(unload().defaultPrevented).toBe(false); + expect(creations).toHaveLength(1); + }); + + it('keeps today\'s behaviour outside a window of its own: no prompt', () => { + mount({}); + fireEvent.change(subjectField(), { target: { value: 'In the main window' } }); + expect(unload().defaultPrevented).toBe(false); + }); + + it('closes itself after the send, and writes no draft on the way out', async () => { + const onClose = vi.fn(); + const onSend = vi.fn(async () => {}); + mount({ + ownWindow: {}, onSend, onClose, + initialData: { to: 'them@example.test', cc: '', bcc: '', subject: 'Off it goes', body: '

Hello

', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: null }, + }); + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + await waitFor(() => expect(onSend).toHaveBeenCalled(), { timeout: 4000 }); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + const saved = creations.length; + expect(unload().defaultPrevented).toBe(false); + expect(creations).toHaveLength(saved); + }); +}); diff --git a/components/email/__tests__/download-all-attachments.test.tsx b/components/email/__tests__/download-all-attachments.test.tsx index 1eaf7d825..65a310b92 100644 --- a/components/email/__tests__/download-all-attachments.test.tsx +++ b/components/email/__tests__/download-all-attachments.test.tsx @@ -10,6 +10,7 @@ import { useSettingsStore } from '@/stores/settings-store'; import { useToastStore } from '@/stores/toast-store'; import { ToastContainer } from '@/components/ui/toast'; import en from '@/locales/en/common.json'; +import { setDownloadGuard } from '@/lib/download-guard'; // Keep the actual viewer handler, translations, JMAP routing/auth/retries and // JSZip. Stub unrelated viewer hosts, as in the other viewer component tests. @@ -312,3 +313,147 @@ describe('Download all attachments', () => { await p.assertZip(['report.txt', 'data.bin'], [alpha, beta]); }); }); + +describe('Save all attachments to a folder', () => { + /** A folder as the picker hands it out; `failOn` makes that file's write fail. */ + function fakeFolder(failOn?: string) { + const files = new Map(); + return { + files, + name: 'Downloads', + async getFileHandle(name: string, options?: { create?: boolean }) { + if (!files.has(name) && !options?.create) throw Object.assign(new Error('missing'), { name: 'NotFoundError' }); + return { + createWritable: async () => ({ + write: async (data: Blob | Uint8Array) => { + if (name === failOn) throw new Error('Disk full'); + files.set(name, data); + }, + close: async () => {}, + abort: async () => {}, + }), + }; + }, + async removeEntry(name: string) { files.delete(name); }, + }; + } + + function stubPicker(result: () => Promise) { + const picker = vi.fn(result); + (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker = picker; + return picker; + } + + const saveButton = () => screen.getByTestId('attachments-save-to-folder'); + + afterEach(() => { + delete (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker; + setDownloadGuard(null); + }); + + it('is not offered where the browser cannot ask for a folder, and the zip stays', async () => { + await openViewer(); + expect(screen.queryByTestId('attachments-save-to-folder')).toBeNull(); + expect(screen.getAllByTitle('Download all')[0]).toBeVisible(); + }); + + it('keeps one row in a narrow pane: with two buttons, just the icons there, and always a name', async () => { + stubPicker(async () => fakeFolder()); + await openViewer(); + const save = saveButton(); + expect(save.getAttribute('aria-label')).toBe(en.pwaWindows.save_all_to_folder); + expect(save.querySelector('span')?.className).toContain('hidden @xl:inline'); + const download = screen.getAllByTitle('Download all')[0]; + expect(download.getAttribute('aria-label')).toBe('Download all'); + expect(download.querySelector('span')?.className).toContain('hidden @xl:inline'); + expect(screen.getAllByTestId('reader-attachments')[0].className).toContain('@container'); + }); + + it('shows the zip button as it was where there is only that one', async () => { + await openViewer(); + const download = screen.getAllByTitle('Download all')[0]; + expect(download.querySelector('span')?.className ?? '').not.toContain('hidden'); + expect(download.textContent).toContain('Download all'); + }); + + it('writes each attachment to the folder, names the folder, and starts no zip', async () => { + const folder = fakeFolder(); + const picker = stubPicker(async () => folder); + const p = await openViewer(); + p.success(); + fireEvent.click(saveButton()); + expect(picker).toHaveBeenCalledWith(expect.objectContaining({ mode: 'readwrite' })); + await waitFor(() => expect(useToastStore.getState().toasts).toEqual([ + expect.objectContaining({ type: 'success', title: 'Saved 2 attachments to Downloads.' }), + ])); + expect([...folder.files.keys()]).toEqual(['report.txt', 'data.bin']); + p.noDownload(); + }); + + it('asks the device policy before the picker: no save-all when downloads are blocked', async () => { + const picker = stubPicker(async () => fakeFolder()); + const p = await openViewer(); + setDownloadGuard(() => false); + fireEvent.click(saveButton()); + await act(async () => {}); + expect(picker).not.toHaveBeenCalled(); + expect(p.fetchMock).not.toHaveBeenCalled(); + }); + + it('asks again before each file: a policy turning on stops the rest and says how many went', async () => { + const folder = fakeFolder(); + stubPicker(async () => folder); + const p = await openViewer(); + p.success(); + let asked = 0; + // The click, then the first file's two checks. Off from the second file on. + setDownloadGuard(() => ++asked <= 3); + fireEvent.click(saveButton()); + await waitFor(() => expect(useToastStore.getState().toasts).toEqual([ + expect.objectContaining({ type: 'warning', title: 'Saved 1 of 2 attachments to Downloads.' }), + ])); + expect([...folder.files.keys()]).toEqual(['report.txt']); + }); + + it('does nothing when the picker is closed', async () => { + stubPicker(async () => { throw Object.assign(new Error('closed'), { name: 'AbortError' }); }); + const p = await openViewer(); + fireEvent.click(saveButton()); + await act(async () => {}); + expect(p.fetchMock).not.toHaveBeenCalled(); + expect(useToastStore.getState().toasts).toEqual([]); + p.noDownload(); + }); + + it('falls back to the zip when the browser refuses the picker', async () => { + stubPicker(async () => { throw Object.assign(new Error('refused'), { name: 'SecurityError' }); }); + const p = await openViewer(); + p.success(); + fireEvent.click(saveButton()); + await waitFor(() => expect(p.downloads).toHaveLength(1), { timeout: 5000 }); + await p.assertZip(['report.txt', 'data.bin'], [alpha, beta]); + }); + + it('says how many were saved when a write fails midway', async () => { + const folder = fakeFolder('data.bin'); + stubPicker(async () => folder); + const p = await openViewer(); + p.success(); + fireEvent.click(saveButton()); + await waitFor(() => expect(useToastStore.getState().toasts).toEqual([ + expect.objectContaining({ type: 'warning', title: 'Saved 1 of 2 attachments to Downloads.' }), + ])); + expect([...folder.files.keys()]).toEqual(['report.txt']); + }); + + it('says so when nothing could be written', async () => { + const folder = fakeFolder('report.txt'); + stubPicker(async () => folder); + const p = await openViewer(); + p.success(); + fireEvent.click(saveButton()); + await waitFor(() => expect(useToastStore.getState().toasts).toEqual([ + expect.objectContaining({ type: 'error', title: 'Could not save the attachments to Downloads.' }), + ])); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 13418b806..06ce32889 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useMvComposeWindow } from "@/components/email/mv-compose-context"; import { useLocale, useTranslations } from "next-intl"; +import { isServerOutage } from "@/lib/jmap/offline-hooks"; import { Button, TOUCH_HIT_AREA } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { Dialog } from "@/components/ui/dialog"; @@ -166,6 +167,7 @@ import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text"; import { fileStorage } from "@/lib/plugin-storage"; import { usePolicyStore } from "@/stores/policy-store"; import { registerComposeFlush } from "@/lib/compose-flush-registry"; +import type { RescuedDraft } from "@/lib/popout/channel"; import { toUnicodeEmail } from "@/lib/idn"; /** @@ -257,6 +259,10 @@ export interface ComposerDraftData { * next save/send rebuilds the draft without them - silent data loss (#849). */ attachments?: Array<{ blobId: string; name?: string; type?: string; size: number; cid?: string; disposition?: string; sourceAccountId?: string }>; + /** The send options picked so far; absent, the defaults. Carried to another window. */ + requestReadReceipt?: boolean; + requestDsn?: boolean; + requireTls?: boolean; /** When set, overrides the header From: and the envelope MAIL FROM, where the server allows it. */ fromOverrideEmail?: string; fromOverrideName?: string; @@ -289,6 +295,13 @@ export interface ComposerDraftData { unsaved?: boolean; } +/** The window a draft moves to; see EmailComposerProps.popOut. */ +export interface ComposerPopOut { + /** True once the composer is on screen there. Until then this one keeps the text. */ + handOver(data: ComposerDraftData): Promise; + cancel(): void; +} + interface EmailComposerProps { /** * Submits the message. Resolves `false` when the host sent nothing (it has @@ -352,6 +365,29 @@ interface EmailComposerProps { */ onDiscardDraft?: (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => void; onSaveState?: (data: ComposerDraftData) => void; + /** + * Offers "Open in new window". Called in the click, since a browser only + * opens a window for a user action: the host opens it and answers with what + * takes the draft, or null when no window opened. The composer then saves, + * hands the draft over under the same draft id, and closes here. + */ + popOut?: () => ComposerPopOut | null; + /** + * This composer is all a window shows (lib/popout). It closes itself once a + * message has gone. Closing the window with unsaved text hands the draft to + * another window to save, or asks first when there is none. + */ + ownWindow?: { + /** The state as the window unloads, for a reload to start from. */ + onUnloadState?: (data: ComposerDraftData) => void; + /** A save reached the server, for other windows showing the Drafts folder. */ + onDraftSaved?: () => void; + /** + * Hands a draft to another window to save as this one closes: a request + * started now wouldn't survive. False when that can't be done safely. + */ + rescueDraft?: (draft: RescuedDraft) => boolean; + }; className?: string; initialDraftText?: string; initialData?: ComposerDraftData | null; @@ -504,6 +540,8 @@ export function EmailComposer({ requestCloseRef, onDiscardDraft, onSaveState, + popOut, + ownWindow, className, initialDraftText: initialDraftTextProp, initialData: initialDataProp, @@ -517,11 +555,13 @@ export function EmailComposer({ }: EmailComposerProps) { const t = useTranslations('email_composer'); const tCommon = useTranslations('common'); + const tWindows = useTranslations('pwaWindows'); const tQuote = useTranslations('quote_header'); const tViewer = useTranslations('email_viewer'); const tClassification = useTranslations('classification'); const tProofreading = useTranslations('proofreading'); const tOffline = useTranslations('offline'); + const tOfflineNotes = useTranslations('offlineConflicts'); const tSendGuards = useTranslations('send_guards'); const timeFormat = useSettingsStore((state) => state.timeFormat); const plainTextModeDefault = useSettingsStore((state) => state.plainTextMode); @@ -989,11 +1029,11 @@ export function EmailComposer({ input.trim() ? [...chips, parseRecipient(input)] : chips; const [isDraggingChipOverCc, setIsDraggingChipOverCc] = useState(false); const [isDraggingChipOverBcc, setIsDraggingChipOverBcc] = useState(false); - const [requestReadReceipt, setRequestReadReceipt] = useState(requestReadReceiptDefault); + const [requestReadReceipt, setRequestReadReceipt] = useState(initialData?.requestReadReceipt ?? requestReadReceiptDefault); // SMTP submission options, offered only when the server advertises the // extension (RFC 8621 §1.3 submissionExtensions). - const [requestDsn, setRequestDsn] = useState(false); - const [requireTls, setRequireTls] = useState(false); + const [requestDsn, setRequestDsn] = useState(initialData?.requestDsn ?? false); + const [requireTls, setRequireTls] = useState(initialData?.requireTls ?? false); // Message priority (#970): normal writes no header at all. const [priority, setPriority] = useState(initialData?.priority ?? 'normal'); const priorityRef = useRef(priority); @@ -1797,6 +1837,9 @@ export function EmailComposer({ if (priority !== initialPriorityRef.current) isDirtyRef.current = true; const initialLabelChoiceRef = useRef(classification.choice); if (classification.choice !== initialLabelChoiceRef.current) isDirtyRef.current = true; + // Text handed in that the server doesn't have yet is unsaved work, however + // little it differs from what this composer started with. + if (initialData?.unsaved && changeVersionRef.current !== savedVersionRef.current) isDirtyRef.current = true; if (uneditedRef.current) isDirtyRef.current = false; // Changes only the user makes, also without typing: a picked recipient, a // removed attachment, a priority. Handed-in files move the count they're @@ -1862,37 +1905,84 @@ export function EmailComposer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [effectiveSessionKey]); + const rescueRequestRef = useRef<() => RescuedDraft | null>(() => null); + const closedRef = useRef(false); + + // The composer as a host needs it to bring it back: after a remount, in + // another window. Read through refs, so a handler set up once stays current. + const snapshotStateRef = useRef<() => ComposerDraftData>(() => { throw new Error('Not mounted'); }); + snapshotStateRef.current = () => ({ + ...stateRef.current, + draftAccountId: draftAccountIdRef.current, + mode, + replyTo, + priority: priorityRef.current, + classification: classificationRef.current.draftChoice, + // Edits the last save didn't cover; a minimised composer with + // none needs no save before an automatic sign-out. + unsaved: !uneditedRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current), + ...(uneditedRef.current ? { unedited: true } : {}), + requestReadReceipt, + requestDsn, + requireTls, + }); + // Auto-save state on unmount (when user navigates away without explicitly closing) useEffect(() => { return () => { if (explicitCloseRef.current) return; // Kept in the host's memory across a remount, handed-in or not. Only // the server stays out of it. - if (onSaveState && (isDirtyRef.current || uneditedRef.current)) { - const s = stateRef.current; - onSaveState({ - ...s, - draftAccountId: draftAccountIdRef.current, - mode, - replyTo, - priority: priorityRef.current, - classification: classificationRef.current.draftChoice, - // Edits the last save didn't cover; a minimised composer with - // none needs no save before an automatic sign-out. - unsaved: !uneditedRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current), - ...(uneditedRef.current ? { unedited: true } : {}), - }); - } + if (onSaveState && (isDirtyRef.current || uneditedRef.current)) onSaveState(snapshotStateRef.current()); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Auto-save draft to server on page close (best-effort) + const ownWindowRef = useRef(ownWindow); + ownWindowRef.current = ownWindow; useEffect(() => { - const handleBeforeUnload = () => { - if (isDirtyRef.current) { - saveDraftRef.current(); + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + const own = ownWindowRef.current; + if (!own) { + if (isDirtyRef.current) { + saveDraftRef.current(); + } + return; } + // This composer closed itself: nothing left to keep. + if (closedRef.current) return; + // Closing now could stop a send halfway. + if (isSendingRef.current) { + event.preventDefault(); + return; + } + // Sent, discarded or saved and closed. + if (explicitCloseRef.current || sentRef.current) return; + if (!isDirtyRef.current) return; + const state = snapshotStateRef.current(); + own.onUnloadState?.(state); + if (!state.unsaved) return; + // A request started while a window closes is dropped, so another window + // saves it, but not when: + // - a plugin may change or stop what is saved, as it only runs here (and + // rescueDraft says no while plugins haven't started); + // - the last save failed or the browser is offline, so that window's + // would likely fail too, with nobody looking; + // - a save is running: its new id is unknown, and two saves make two drafts. + const canHandOver = own.rescueDraft + && emailHooks.onBeforeDraftAutoSave.size === 0 + && !saveFailedRef.current + && navigator.onLine !== false + && !inflightSaveRef.current; + if (canHandOver) { + const draft = rescueRequestRef.current(); + if (!draft || own.rescueDraft?.(draft)) return; + } + // Nobody can take it. The prompt keeps the window open: "stay" lets the + // save run, and "leave" is the user's own choice. + saveDraftRef.current(); + event.preventDefault(); }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); @@ -2702,28 +2792,17 @@ export function EmailComposer({ }, [previewAttachment, composerClient]); // Auto-save draft functionality - // `auto`: the debounced save while typing. Only that one may be skipped - // by a plugin; saves on close and before send always run, or the text - // would be lost. - const saveDraftOnce = async (auto = false): Promise => { - if (!client || !composerClient) return null; - const versionAtStart = changeVersionRef.current; + const draftRecipients = () => ({ + toAddresses: expandRecipients(withInput(to, toInput)).map(r => formatRecipient(r.name, r.email)), + ccAddresses: expandRecipients(withInput(cc, ccInput)).map(r => formatRecipient(r.name, r.email)), + bccAddresses: expandRecipients(withInput(bcc, bccInput)).map(r => formatRecipient(r.name, r.email)), + }); - const toAddresses = expandRecipients(withInput(to, toInput)).map(r => formatRecipient(r.name, r.email)); - const ccAddresses = expandRecipients(withInput(cc, ccInput)).map(r => formatRecipient(r.name, r.email)); - const bccAddresses = expandRecipients(withInput(bcc, bccInput)).map(r => formatRecipient(r.name, r.email)); - - if (!toAddresses.length && !subject && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) { - return null; - } - - try { - await rehomeForeignBlobs(); - } catch (error) { - // The save below then fails with blobNotFound and reports it. - debug.warn('email', 'Failed to copy forwarded parts to the sending account:', error); - } + const draftHasContent = (toAddresses: string[]) => + !!toAddresses.length || !!subject || !!(plainTextMode ? body.trim() : htmlToPlainText(body).trim()); + // What a saved draft holds, as of now. No awaits: a closing window has none to spare. + const draftContent = () => { // Prepare attachments for draft. cid/disposition ride along so inline // parts of a re-opened draft keep matching the body's cid: references. const uploadedAttachments = attachmentsRef.current @@ -2757,6 +2836,82 @@ export function EmailComposer({ : appendPlainTextSignature(body, signatureIdentity, { separator: signatureSeparatorEnabled })) : htmlToPlainText(draftHtmlBody!, imageAltText); + return { uploadedAttachments, draftHtmlBody, draftTextBody }; + }; + + // Get the selected identity or primary identity + // Generate sub-addressed email if tag is set + const draftSender = () => { + const identityFromEmail = currentIdentity?.email + ? subAddressTag + ? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter) + : currentIdentity.email + : undefined; + const fromEmail = (fromOverrideEnabled && fromOverrideEmail.trim()) + ? fromOverrideEmail.trim() + : identityFromEmail; + const fromName = (fromOverrideEnabled && fromOverrideEmail.trim()) + ? (fromOverrideName.trim() || undefined) + : (currentIdentity?.name || undefined); + return { fromEmail, fromName }; + }; + + // The save below, for another window to make as this one closes. Null with nothing to save. + rescueRequestRef.current = () => { + const { toAddresses, ccAddresses, bccAddresses } = draftRecipients(); + if (!draftHasContent(toAddresses)) return null; + const { uploadedAttachments, draftHtmlBody, draftTextBody } = draftContent(); + const { fromEmail, fromName } = draftSender(); + const login = loginFor(currentIdentityParts.localAccountId); + const previousLogin = draftLocalAccountIdRef.current; + // A version saved through another login can't be replaced from this one. + const replaces = !previousLogin || previousLogin === login; + return { + localAccountId: login, + to: toAddresses, + subject, + body: draftTextBody, + cc: ccAddresses, + bcc: bccAddresses, + identityId: currentIdentityRawId, + fromEmail, + draftId: replaces ? draftIdRef.current || undefined : undefined, + attachments: uploadedAttachments, + fromName, + htmlBody: draftHtmlBody, + inReplyTo: replyThreadingHeaders?.inReplyTo, + references: replyThreadingHeaders?.references, + options: { + priority, + previousDraftAccountId: replaces ? draftAccountIdRef.current : undefined, + classification: classificationRef.current.draftChoice, + }, + state: snapshotStateRef.current(), + }; + }; + + // `auto`: the debounced save while typing. Only that one may be skipped + // by a plugin; saves on close and before send always run, or the text + // would be lost. + const saveDraftOnce = async (auto = false): Promise => { + if (!client || !composerClient) return null; + const versionAtStart = changeVersionRef.current; + + const { toAddresses, ccAddresses, bccAddresses } = draftRecipients(); + + if (!draftHasContent(toAddresses)) { + return null; + } + + try { + await rehomeForeignBlobs(); + } catch (error) { + // The save below then fails with blobNotFound and reports it. + debug.warn('email', 'Failed to copy forwarded parts to the sending account:', error); + } + + const { uploadedAttachments, draftHtmlBody, draftTextBody } = draftContent(); + // Create a hash of current data to compare with last saved. Hashing the // composed bodies (not the raw editor body) means a signature that changed // under an unchanged body - switching identity, toggling the separator - @@ -2776,19 +2931,7 @@ export function EmailComposer({ setSaveStatus('saving'); - // Get the selected identity or primary identity - // Generate sub-addressed email if tag is set - const identityFromEmail = currentIdentity?.email - ? subAddressTag - ? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter) - : currentIdentity.email - : undefined; - const fromEmail = (fromOverrideEnabled && fromOverrideEmail.trim()) - ? fromOverrideEmail.trim() - : identityFromEmail; - const fromName = (fromOverrideEnabled && fromOverrideEmail.trim()) - ? (fromOverrideName.trim() || undefined) - : (currentIdentity?.name || undefined); + const { fromEmail, fromName } = draftSender(); try { const previousDraftId = draftIdRef.current; @@ -2943,6 +3086,7 @@ export function EmailComposer({ // Only the save the user asked for is read out: autosave would say it // every minute, and the save before sending is followed by "Sent". if (opts.announce && id && !saveFailedRef.current) announceStatus(t('draft_saved')); + if (id && !saveFailedRef.current) ownWindowRef.current?.onDraftSaved?.(); return id; })(); inflightSaveRef.current = promise; @@ -4040,8 +4184,10 @@ export function EmailComposer({ // Clear ref so unmount effect doesn't re-save stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, draftAccountId: undefined, draftLocalAccountId: undefined, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '', attachments: [], plainTextMode }; // The host closes the composer from onSend or onScheduledSendCreated; - // a send the plugin made itself reaches neither (#1207). - if (sendHandledByPlugin && !effectiveDelayedUntil) cleanClose(); + // a send the plugin made itself reaches neither (#1207). A window of + // its own closes only now: closing it stops whatever is still running, + // such as removing the draft's last version above. + if (ownWindowRef.current || (sendHandledByPlugin && !effectiveDelayedUntil)) cleanClose(); } catch (err) { // Not sent, so the composer stays open and is an unsent draft again. explicitCloseRef.current = false; @@ -4069,6 +4215,8 @@ export function EmailComposer({ } else if (err instanceof SubmissionQuotaError) { // At a full submission quota only an administrator can help. toast.error(sendFailureMessage(err, t)); + } else if (isServerOutage(err)) { + toast.error(t('send_failed'), { message: tOfflineNotes('server_not_answering') }); } else if (err instanceof Error && OFFLINE_SEND_ERRORS[err.name]) { // Offline mode (#523) refused to keep it: says why, in the user's language. toast.error(t('send_failed'), { message: tOffline(OFFLINE_SEND_ERRORS[err.name]) }); @@ -4093,6 +4241,7 @@ export function EmailComposer({ const afterCloseRef = useRef<(() => void) | null>(null); const emitClose = () => { + closedRef.current = true; onClose?.(); const afterClose = afterCloseRef.current; afterCloseRef.current = null; @@ -4181,6 +4330,71 @@ export function EmailComposer({ } }; + // Moves the draft to its own window. The window opens first, inside the + // click, and waits; this window saves what the server doesn't have yet, + // gives it the draft under the same id, and closes the composer here. + const [isPoppingOut, setIsPoppingOut] = useState(false); + const poppingOutRef = useRef(false); + const handlePopOut = async () => { + if (!popOut || poppingOutRef.current || isSendingRef.current || saveCloseAttemptRef.current) return; + const target = popOut(); + if (!target) { + toast.error(tWindows('popup_blocked')); + return; + } + poppingOutRef.current = true; + setIsPoppingOut(true); + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + try { + const uploads = await waitForPendingUploads(() => attachmentsRef.current, () => false); + if (uploads === 'failed') { + target.cancel(); + toast.error(t('save_failed')); + return; + } + if (isDirtyRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current)) { + // A failed save doesn't stop the move: the text goes along, marked + // unsaved, and this composer lets go only once it is on screen there. + await saveDraftRef.current().catch(() => null); + } + const data = snapshotStateRef.current(); + const shown = await target.handOver({ + ...data, + draftId: draftIdRef.current ?? lastDraftIdRef.current, + draftLocalAccountId: draftLocalAccountIdRef.current, + }); + // Not on screen there, so this is still the only copy of the text. + if (!shown) { + target.cancel(); + toast.error(tWindows('window_not_ready')); + return; + } + explicitCloseRef.current = true; + sendCancelledRef.current = true; + emitClose(); + } finally { + poppingOutRef.current = false; + setIsPoppingOut(false); + } + }; + + const popOutButton = (className: string, iconClassName: string) => popOut && ( + + ); + const handleDiscardAndClose = () => { cancelSaveAndClose(); sendCancelledRef.current = true; @@ -4322,6 +4536,8 @@ export function EmailComposer({
)} + {popOutButton("grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground", "h-4 w-4")} )} {rightPanelToggle} + {popOutButton("hidden md:grid h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground", "w-4 h-4")} {/* Close at the top end (R4). On a phone it moves to the start, as the phone's mail apps have it, with Send at the end. */} void; onBatchForwardAsAttachment?: () => void; + /** Opens the message in its own window (single message). */ + onOpenInWindow?: () => void; /** Save the message as .eml, print it, show its source (single message). */ onExport?: () => void; onPrint?: () => void; @@ -183,6 +185,7 @@ export function EmailContextMenu({ onRescheduleScheduled, copyTargets, onCopyToAccount, + onOpenInWindow, onExport, onPrint, onViewSource, @@ -308,6 +311,7 @@ export function EmailContextMenu({ onRescheduleScheduled, onCancelScheduledForEdit, onCancelScheduledSend, + onOpenInWindow, onExport, onPrint, onViewSource, diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index bebd4f42c..fe25f6dc0 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -27,6 +27,7 @@ import { useEmailStore, resolveEmailBlobContext, ArchiveMailboxNotFoundError, re import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder"; import { useShallow } from "zustand/react/shallow"; import { useAuthStore } from "@/stores/auth-store"; +import { useOpenEmailInWindow } from "@/hooks/use-popouts-available"; import { useSettingsStore } from "@/stores/settings-store"; import { useListRowShape } from "@/hooks/use-list-row-shape"; import { MailListRowsSkeleton, type MailRowShape } from "@/components/boot-skeleton/mail-skeleton"; @@ -177,6 +178,7 @@ export function EmailList({ }: EmailListProps) { const t = useTranslations('email_list'); const tContextMenu = useTranslations('context_menu'); + const openInWindow = useOpenEmailInWindow(); const tSpam = useTranslations('email_viewer.spam'); const tNotifications = useTranslations('notifications'); const tViewer = useTranslations('email_viewer'); @@ -1360,6 +1362,7 @@ export function EmailList({ : [contextMenuEmail!]; await copyToAccount(emails, accountId, mailboxId); }} + onOpenInWindow={openInWindow && contextMenuEmail ? () => openInWindow(contextMenuEmail) : undefined} // Export, print and View source follow the device policy of the row's own account. onExport={contextMenuEmail && !canExport(contextMenuEmail) ? undefined : () => { const target = contextMenuEmail!; diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 9162f0d14..e75a260ca 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -82,6 +82,7 @@ import { MailOpen, Loader2, Printer, + ExternalLink, FileText, File, Shield, @@ -158,6 +159,7 @@ import { requestInternalMailto } from "@/lib/protocol-handlers/mailto"; import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview"; import { openPreviewInNewWindow } from "@/lib/standalone-display"; import { downloadAllowed, saveBlobAs } from "@/lib/download-guard"; +import { canPickFolder, createUniqueNamer, isPickerCancelled, pickFolder, saveFilesToFolder, type FolderHandle } from "@/lib/attachments/save-to-folder"; import { useDevicePolicy } from "@/hooks/use-device-policy"; import { Watermark, useWatermarkText } from "@/components/device/watermark"; import { watermarkForPrint } from "@/lib/watermark"; @@ -285,6 +287,8 @@ interface EmailViewerProps { * minimize button; `isFullscreen` reflects the host's current state. */ onToggleFullscreen?: () => void; + /** Opens this message in its own window; left out where that isn't on offer. */ + onOpenInWindow?: () => void; isFullscreen?: boolean; currentUserEmail?: string; currentUserName?: string; @@ -1318,6 +1322,7 @@ export function EmailViewer({ onRescheduleScheduled, onCompose, onToggleFullscreen, + onOpenInWindow, isFullscreen = false, currentUserEmail, currentUserName, @@ -1329,6 +1334,7 @@ export function EmailViewer({ }: EmailViewerProps) { const t = useTranslations('email_viewer'); const archiveActionEnabled = useArchiveActionEnabled(); + const tWindows = useTranslations('pwaWindows'); const tComposer = useTranslations('email_composer'); const tNotifications = useTranslations('notifications'); const tImport = useTranslations('mail_import'); @@ -3005,10 +3011,24 @@ export function EmailViewer({ saveBlobAs(blob, downloadName, { kind: 'attachment', subject: email }); }, [onDownloadAttachment, email, resolveAttachmentName]); + // An attachment's content for "download all" and "save all": fetched + // through the JMAP client when it is blob-backed, the already decoded bytes + // for S/MIME-decrypted and TNEF-extracted ones. Null when it has none. + const readAttachmentContent = useCallback(async (attachment: EffectiveAttachment, name: string): Promise | null> => { + if (attachment.blobId && blobClient) { + return blobClient.fetchBlob(attachment.blobId, attachment.name || name, attachment.type, blobAccountId); + } + if (attachment.tnefData) return attachment.tnefData as Uint8Array; + if (attachment.decryptedAttachment) { + const bytes = getAttachmentContentBytes(attachment.decryptedAttachment); + if (bytes && bytes.byteLength > 0) return bytes as Uint8Array; + } + return null; + }, [blobClient, blobAccountId]); + // Bundle every attachment of this email into a single .zip and download it. - // Fetches blob-backed attachments through the JMAP client and reuses already - // decoded bytes for S/MIME-decrypted and TNEF-extracted ones. Individual - // failures are skipped so a single bad blob doesn't sink the whole archive. + // Individual failures are skipped so a single bad blob doesn't sink the + // whole archive. const handleDownloadAllAttachments = useCallback(async () => { if (isDownloadingAll || effectiveAttachments.length === 0) return; if (!downloadAllowed('attachment', email)) return; @@ -3016,39 +3036,17 @@ export function EmailViewer({ try { const { default: JSZip } = await import('jszip'); const zip = new JSZip(); - const used = new Set(); - // Zip entries must be unique; suffix collisions with " (n)" before the - // extension so duplicates stay recognisable. - const uniqueName = (raw: string): string => { - const base = raw || 'attachment'; - if (!used.has(base)) { used.add(base); return base; } - const dot = base.lastIndexOf('.'); - const stem = dot > 0 ? base.slice(0, dot) : base; - const ext = dot > 0 ? base.slice(dot) : ''; - let i = 1; - let candidate = `${stem} (${i})${ext}`; - while (used.has(candidate)) { i++; candidate = `${stem} (${i})${ext}`; } - used.add(candidate); - return candidate; - }; + // Zip entries must be unique. + const uniqueName = createUniqueNamer(); let added = 0; for (const attachment of effectiveAttachments) { const entryName = uniqueName(getAttachmentDisplayName(attachment.name, attachment.type)); try { - if (attachment.blobId && blobClient) { - const blob = await blobClient.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type, blobAccountId); - zip.file(entryName, blob); + const content = await readAttachmentContent(attachment, entryName); + if (content) { + zip.file(entryName, content); added++; - } else if (attachment.tnefData) { - zip.file(entryName, attachment.tnefData); - added++; - } else if (attachment.decryptedAttachment) { - const bytes = getAttachmentContentBytes(attachment.decryptedAttachment); - if (bytes && bytes.byteLength > 0) { - zip.file(entryName, bytes); - added++; - } } } catch { // Skip individual failures; remaining attachments still bundle. @@ -3071,7 +3069,43 @@ export function EmailViewer({ } finally { setIsDownloadingAll(false); } - }, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email, t, tFiles]); + }, [isDownloadingAll, effectiveAttachments, readAttachmentContent, email, t, tFiles]); + + // Save every attachment into a folder the user picks. These writes don't + // pass saveBlobAs, so the device policy is asked here: before the picker, + // and before each file. A browser that refuses the picker gets the zip. + const handleSaveAllToFolder = useCallback(async () => { + if (isDownloadingAll || effectiveAttachments.length === 0) return; + if (!downloadAllowed('attachment', email)) return; + let folder: FolderHandle; + try { + folder = await pickFolder(); + } catch (error) { + if (!isPickerCancelled(error)) void handleDownloadAllAttachments(); + return; + } + setIsDownloadingAll(true); + try { + const result = await saveFilesToFolder( + folder, + effectiveAttachments.map((attachment) => { + const name = getAttachmentDisplayName(attachment.name, attachment.type); + return { name, read: () => readAttachmentContent(attachment, name) }; + }), + () => downloadAllowed('attachment', email), + ); + if (result.saved === result.total) { + toast.success(tWindows('saved_all', { count: result.saved, folder: result.folder })); + } else if (result.saved > 0) { + toast.warning(tWindows('saved_partial', { saved: result.saved, total: result.total, folder: result.folder })); + } else if (result.stopped !== 'policy') { + // The policy said why itself. + toast.error(tWindows('save_failed', { folder: result.folder })); + } + } finally { + setIsDownloadingAll(false); + } + }, [isDownloadingAll, effectiveAttachments, readAttachmentContent, handleDownloadAllAttachments, email, tWindows]); // What the attachment bar and the attachment cards share. const attachmentCardsRef = useRef(null); @@ -3082,6 +3116,7 @@ export function EmailViewer({ onOpen: handleEffectiveAttachmentOpen, onDownload: handleEffectiveAttachmentDownload, onDownloadAll: handleDownloadAllAttachments, + onSaveAllToFolder: canPickFolder() ? () => void handleSaveAllToFolder() : undefined, downloadingAll: isDownloadingAll, downloadsOff, // Save to Files would get around the policy: Files downloads are off too, but the copy would stay. @@ -4479,6 +4514,20 @@ ${printWatermark ? printWatermark.html : ''} {isDark ? : } + {onOpenInWindow && !mvViewer && ( + + )} + {/* Fullscreen toggle - hidden on mobile (already fullscreen there). Never overflows into the More menu: in fullscreen this button is the way back out, so it must stay visible. */} @@ -4630,7 +4679,7 @@ ${printWatermark ? printWatermark.html : ''} inset (same treatment as the preview modals). (#936) */}
{moreMenuSub ? ( )} + {onOpenInWindow && ( + + )} {onToggleFullscreen && ( + )} + {attachments.length > 1 && !downloadsOff && onSaveAllToFolder && ( + )}
diff --git a/components/error/app-error-boundary.tsx b/components/error/app-error-boundary.tsx index 5b36c5037..d78a6a5a7 100644 --- a/components/error/app-error-boundary.tsx +++ b/components/error/app-error-boundary.tsx @@ -11,7 +11,7 @@ function AppErrorLayout({ kind, ...props }: FallbackProps & { kind: "page" | "se const Fallback = kind === "settings" ? SettingsErrorFallback : PageErrorFallback; // The navigation stays, so one app crashing doesn't strand the user in it. return ( -
+
{!isMobile && (
diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index 0942d9f7b..04fe72301 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -813,7 +813,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } return ( -
+
{!isEmbedded && {tShell("skip_to_content")}} {refreshIndicator} diff --git a/components/files/preview-shell.tsx b/components/files/preview-shell.tsx index e1c00de3e..50ada2993 100644 --- a/components/files/preview-shell.tsx +++ b/components/files/preview-shell.tsx @@ -82,7 +82,7 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c {/* Full-screen under `viewport-fit=cover`: the bar pads itself out of the iOS status bar (#936). The insets are physical, so pl/pr. */}
e.stopPropagation()} >

{name}

diff --git a/components/files/wopi-editor.tsx b/components/files/wopi-editor.tsx index afe6f95fe..bb3f2d5e9 100644 --- a/components/files/wopi-editor.tsx +++ b/components/files/wopi-editor.tsx @@ -124,7 +124,7 @@ export function WopiEditor({ target, accountId, slot, onClose, closeOnBack = fal role="dialog" aria-modal="true" aria-labelledby={titleId} - className="fixed inset-0 z-[100] bg-background flex flex-col" + className="fixed inset-0 z-[100] bg-background flex flex-col pt-[var(--titlebar-inset)]" >

{target.name}

diff --git a/components/layout/window-title-bar.tsx b/components/layout/window-title-bar.tsx new file mode 100644 index 000000000..7f4e306fa --- /dev/null +++ b/components/layout/window-title-bar.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { applyTitlebarInset, watchTitlebarArea, type TitlebarArea } from "@/lib/window-controls-overlay"; + +/** + * The strip above the app in a window without a native title bar: the part + * the user drags the window by, with the window's title in small text. The + * native window controls sit next to it, outside the area it is given. The + * page roots leave this row empty through --titlebar-inset. + * + * Renders nothing, and changes nothing on the page, without an overlay. It + * repeats the title for sighted users only, so it stays out of the + * accessibility tree and out of the tab order. + */ +export function WindowTitleBar() { + const [area, setArea] = useState(null); + const [title, setTitle] = useState(""); + + useEffect(() => watchTitlebarArea((next) => { + applyTitlebarInset(next); + setArea(next); + }), []); + + const visible = area !== null; + useEffect(() => { + if (!visible) return; + const read = () => setTitle(document.title); + read(); + // Each surface sets its own title, and Next puts back on navigation. + const observer = new MutationObserver(read); + observer.observe(document.head, { childList: true, subtree: true, characterData: true }); + return () => observer.disconnect(); + }, [visible]); + + if (!area) return null; + + return ( + <div + aria-hidden="true" + data-window-title-bar="" + className="window-title-bar fixed z-[100002] flex items-center bg-background ps-3 pe-3 text-xs text-muted-foreground select-none" + style={{ top: area.y, left: area.x, width: area.width, height: area.height }} + > + <span className="truncate">{title}</span> + </div> + ); +} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 70e8f5d98..d475f4f2c 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -150,6 +150,9 @@ import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handler import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose"; import { onOpenEmail, type OpenEmailHandler, type OpenEmailOutcome } from "@/lib/plugin-sandbox/open-email"; import { mailtoComposerDraft } from "./mailto-draft"; +import { useOpenEmailInWindow, usePopoutsAvailable } from "@/hooks/use-popouts-available"; +import { openComposerWindow } from "@/lib/popout/open"; +import { onRescuedDraft, takeRescuedDraft } from "@/lib/popout/rescued-drafts"; import { getQuoteBodies } from "@/lib/email-composer-utils"; import { emailHooks } from "@/lib/plugin-hooks"; import { afterRequestDispatch } from "@/lib/after-request-dispatch"; @@ -2261,6 +2264,39 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // another account's message under the same id. const composerSource = usePinnedPerSession(composerSessionId, selectedEmail ? emailWriteSource(selectedEmail, client) : undefined); + const popoutsOn = usePopoutsAvailable(); + const openEmailInWindow = useOpenEmailInWindow(); + // Text from a closing pop-out that couldn't be saved: the "Continue draft" + // slot, once it is free. + useEffect(() => { + const adopt = () => { + if (pendingDraft || showComposer) return; + const draft = takeRescuedDraft(); + if (draft) setPendingDraft(draft); + }; + adopt(); + return onRescuedDraft(adopt); + }, [pendingDraft, showComposer]); + const composerPopOut = () => { + const target = openComposerWindow(activeAccountId); + if (!target) return null; + return { + cancel: target.cancel, + handOver: (state: ComposerDraftData) => target.handOver({ + mode: state.mode, + replyTo: state.replyTo, + initialData: state, + sourceEmailId: composerSourceEmail?.id, + sourceEmailSource: composerSource, + composeFromAccountEmail: resolveComposeAccountEmail( + mailboxes, + selectedMailbox, + useAccountStore.getState().getAccountById(viewingAccountId ?? activeAccountId ?? '')?.email, + ), + }), + }; + }; + const handleEmailSend = async (data: { to: string[]; cc: string[]; @@ -5012,6 +5048,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { handleDiscardDraft(draftId, draft); setPendingDraft(null); }} + popOut={popoutsOn && !isEmbedded ? composerPopOut : undefined} /> </ErrorBoundary> ); @@ -5022,7 +5059,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return ( <DragDropProvider> - <div ref={appRootRef} className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}> + <div ref={appRootRef} className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}> {/* Rail and folders are many Tab stops: jump past them (WCAG 2.4.1). */} <a href="#message-list" @@ -5135,7 +5172,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { isEmbedded ? (isMobile || isTablet ? cn( - "absolute inset-y-0 start-0 w-72 pt-[env(safe-area-inset-top)]", + "absolute inset-y-0 start-0 w-72 pt-[var(--app-top-inset)]", "transform transition-transform duration-300 ease-in-out", // translate-x is physical, so RTL needs the opposite // sign or the drawer slides off the wrong edge. @@ -5146,7 +5183,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Mobile/Tablet: fixed overlay // start-0 rather than left-0: in RTL the drawer belongs on // the right, matching the hamburger that opens it. - "max-lg:fixed max-lg:inset-y-0 max-lg:start-0 max-lg:w-72 max-lg:pt-[env(safe-area-inset-top)]", + "max-lg:fixed max-lg:inset-y-0 max-lg:start-0 max-lg:w-72 max-lg:pt-[var(--app-top-inset)]", "max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out", !sidebarOpen && "max-lg:-translate-x-full max-lg:rtl:translate-x-full", // Desktop: normal flow @@ -5288,7 +5325,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { {(!isMobile || mobileSearchOpen) && ( <div className={cn( "border-b border-border bg-background", - isMobile && mobileSearchOpen && "fixed inset-0 z-50 overflow-y-auto" + isMobile && mobileSearchOpen && "fixed inset-0 z-50 overflow-y-auto pt-[var(--titlebar-inset)]" )}> {isMobile && mobileSearchOpen && ( <div className="h-14 px-2 flex items-center border-b border-border"> @@ -5650,7 +5687,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { isHorizontalMailLayout ? "min-h-0" : "h-full", // Mobile: full screen overlay when active "max-md:fixed max-md:inset-0 max-md:z-30", - "max-md:h-full max-md:pt-[env(safe-area-inset-top)]", + "max-md:h-full max-md:pt-[var(--app-top-inset)]", isMobile && mobileView !== "viewer" && "max-md:hidden", // Tablet/Desktop: relative. Pane-mobile instead overlays the // pane (absolute within the relative content row above) - the @@ -5835,6 +5872,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } : undefined} mvInlineComposer={mvInlineReply ? <MvComposeInline onPopOut={handleMvPopOut}>{composerNode}</MvComposeInline> : undefined} onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined} + onOpenInWindow={!isEmbedded && !isMobile && openEmailInWindow && selectedEmail ? () => openEmailInWindow(selectedEmail) : undefined} isFullscreen={fullscreenReading} onShowShortcuts={() => setShowShortcutsModal(true)} onEditDraft={handleEditDraft} diff --git a/components/notifications/notification-center.tsx b/components/notifications/notification-center.tsx index 34204bf66..a3961e7f5 100644 --- a/components/notifications/notification-center.tsx +++ b/components/notifications/notification-center.tsx @@ -158,7 +158,7 @@ export function NotificationSheet({ onClose }: { onClose: () => void }) { role="dialog" aria-modal="true" aria-label={t("title")} - className="fixed inset-0 z-50 flex flex-col bg-background pt-[env(safe-area-inset-top)]" + className="fixed inset-0 z-50 flex flex-col bg-background pt-[var(--app-top-inset)]" > <NotificationPanel onClose={onClose} sheet className="flex-1 min-h-0" /> </div>, diff --git a/components/offline/__tests__/account-storage.test.tsx b/components/offline/__tests__/account-storage.test.tsx index 1e0aeb682..7fcde2e2a 100644 --- a/components/offline/__tests__/account-storage.test.tsx +++ b/components/offline/__tests__/account-storage.test.tsx @@ -101,7 +101,7 @@ describe('what an account keeps', () => { const select = screen.getByText('attachment_cap_label').closest('[data-search-label]')!.querySelector('select') as HTMLSelectElement; expect([...select.options].map((o) => o.value)).toEqual(['5', '10']); fireEvent.change(select, { target: { value: '5' } }); - expect(offline.chooseScope).toHaveBeenCalledWith('acc', { maxAttachmentMb: 5 }); + expect(offline.chooseScope).toHaveBeenCalledWith('acc', { maxAttachmentBytes: 5 * 1024 * 1024 }); }); it('says when the total is set by the organisation', () => { @@ -110,6 +110,13 @@ describe('what an account keeps', () => { expect(screen.getByTestId('offline-total-locked').textContent).toContain('total_locked'); }); + it('locks the switch when the desktop policy forces it', () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, policy: { get: () => ({ OfflineEnabled: { value: true, locked: true, source: 'machine' } }) } }; + render(<OfflineSettings />); + expect(screen.getByTestId('offline-switch').closest('[inert]')).not.toBeNull(); + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; + }); + it('shows nothing of this with offline off', () => { useOfflineStore.setState({ enabled: [] }); render(<OfflineSettings />); @@ -128,7 +135,7 @@ describe('what it takes', () => { }); it('shows the time of the last sync from before a reload', () => { - useOfflineStore.setState({ health: { acc: { opened: true, lastSync: Date.now() - 60_000 } } }); + useOfflineStore.setState({ health: { acc: { lastSync: Date.now() - 60_000 } } }); render(<OfflineSettings />); expect(screen.getByTestId('offline-status').textContent).toContain('usage_updated'); }); @@ -182,11 +189,11 @@ describe('what the browser may do', () => { }); it('warns about a store it deleted, with the count, until read', () => { - useOfflineStore.setState({ health: { acc: { opened: true, evicted: { at: 1, lost: 2 } } } }); + useOfflineStore.setState({ health: { acc: { reset: { at: 1, lost: 2 } } } }); render(<OfflineSettings />); expect(screen.getByTestId('offline-evicted').textContent).toContain('evicted_lost'); fireEvent.click(within(screen.getByTestId('offline-evicted')).getByRole('button', { name: 'evicted_dismiss' })); - expect(useOfflineStore.getState().health.acc.evicted).toBeUndefined(); + expect(useOfflineStore.getState().health.acc.reset).toBeUndefined(); }); it('says the device is full, with what to do', () => { diff --git a/components/offline/__tests__/offline-host.test.tsx b/components/offline/__tests__/offline-host.test.tsx new file mode 100644 index 000000000..1cceb0a23 --- /dev/null +++ b/components/offline/__tests__/offline-host.test.tsx @@ -0,0 +1,55 @@ +import 'fake-indexeddb/auto'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { OfflineHost } from '../offline-host'; +import { queueSend } from '@/lib/offline/queue'; +import { useOfflineStore, writeEnabled } from '@/lib/offline/state'; +import { passSignOutGates } from '@/lib/sign-out-cleanup'; +import { setStoreBackend } from '@/lib/store/client/api'; +import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; +import { wipeEverything } from '@/lib/offline/offline-mode'; +import { useAccountStore } from '@/stores/account-store'; + +let store: InProcessStore; + +beforeEach(() => { + store = new InProcessStore(); + setStoreBackend(store); + localStorage.clear(); + useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [] }); + useAccountStore.setState({ accounts: [{ id: 'acc' }] as never }); + writeEnabled(['acc']); +}); + +afterEach(async () => { + cleanup(); + await wipeEverything(); + setStoreBackend(null); +}); + +describe('the question before a sign-out', () => { + it('counts what the queue holds, though the screen has seen none of it', async () => { + await queueSend('acc', { subject: 'Hello', to: [] }, { kind: 'email', args: [] }); + useOfflineStore.setState({ outbox: [] }); + render(<OfflineHost />); + const answer = passSignOutGates('acc'); + fireEvent.click(await screen.findByRole('button', { name: /signout\.confirm/ })); + expect(await answer).toBe(true); + }); + + it('asks about a change that hasn\'t reached the server, on its own', async () => { + await store.ensureOpen('acc'); + store.dbs.get('acc')!.run("INSERT INTO calendar_pending (jmap, record_id, kind, patch, queued_at) VALUES ('j1', 'ev', 'update', '{}', 1)"); + render(<OfflineHost />); + const answer = passSignOutGates('acc'); + expect(await screen.findByText(/signout_changes_title/)).toBeTruthy(); + fireEvent.click(await screen.findByRole('button', { name: /signout\.confirm/ })); + expect(await answer).toBe(true); + }); + + it('asks nothing with nothing waiting', async () => { + render(<OfflineHost />); + expect(await passSignOutGates('acc')).toBe(true); + expect(screen.queryByRole('button', { name: /signout\.confirm/ })).toBeNull(); + }); +}); diff --git a/components/offline/__tests__/offline-settings.test.tsx b/components/offline/__tests__/offline-settings.test.tsx index 73e13bbbf..5a9e35962 100644 --- a/components/offline/__tests__/offline-settings.test.tsx +++ b/components/offline/__tests__/offline-settings.test.tsx @@ -35,6 +35,17 @@ beforeEach(() => { }); describe('Settings > Offline', () => { + it('says so in a private window, with no switch to turn on', () => { + useOfflineStore.setState({ storageBlocked: true }); + try { + render(<OfflineSettings />); + expect(screen.getByTestId('offline-private')).toBeTruthy(); + expect(screen.queryByTestId('offline-switch')).toBeNull(); + } finally { + useOfflineStore.setState({ storageBlocked: false }); + } + }); + it('is off until the user turns it on in this browser', () => { render(<OfflineSettings />); const toggle = screen.getByTestId('offline-switch'); diff --git a/components/offline/account-storage.tsx b/components/offline/account-storage.tsx index f86ed4a8b..773ca78e6 100644 --- a/components/offline/account-storage.tsx +++ b/components/offline/account-storage.tsx @@ -8,7 +8,7 @@ import { OrgLockedNote } from '@/components/settings/org-locked-note'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { formatFileSize } from '@/lib/utils'; -import { chooseScope, clearAccount, disableOffline, dismissEvicted, enableOffline, scopeForAccount } from '@/lib/offline/offline-mode'; +import { chooseScope, clearAccount, disableOffline, dismissStoreReset, enableOffline, scopeForAccount } from '@/lib/offline/offline-mode'; import { attachmentCapOptions, maxAttachmentMb, windowOptions } from '@/lib/offline/scope'; import { useOfflineStore } from '@/lib/offline/state'; import { minutesLeft, progressWorthShowing, type SyncProgress } from '@/lib/offline/sync-progress'; @@ -51,7 +51,7 @@ export function AccountStorage({ account, primary, heading }: { account: string; const usage = useOfflineStore((s) => s.usage[account]); const syncing = useOfflineStore((s) => s.syncing.includes(account)); const lastSynced = useOfflineStore((s) => s.lastSyncedAt[account] ?? s.health[account]?.lastSync); - const evicted = useOfflineStore((s) => s.health[account]?.evicted); + const storeReset = useOfflineStore((s) => s.health[account]?.reset); const storageFull = useOfflineStore((s) => !!s.storageFull[account]); const progress = useOfflineStore((s) => s.progress[account]); const unsent = useOfflineStore((s) => s.outbox.filter((row) => row.account === account).length); @@ -89,7 +89,7 @@ export function AccountStorage({ account, primary, heading }: { account: string; return ( <div className={heading ? 'rounded-md border border-border px-3' : undefined} data-testid={testId('offline-account')}> {heading && <p className="pt-3 text-sm font-semibold text-foreground [overflow-wrap:anywhere]">{heading}</p>} - <SettingItem label={t('keep_label')} description={tStorage('keep_description')}> + <SettingItem label={t('keep_label')} description={tStorage('keep_description')} locked={locked.forced}> <ToggleSwitch checked={enabled} testId={testId('offline-switch')} @@ -100,13 +100,13 @@ export function AccountStorage({ account, primary, heading }: { account: string; }} /> </SettingItem> - {evicted && ( + {storeReset && ( <div className="my-2 flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between" role="alert" data-testid="offline-evicted"> <p className="min-w-0"> {tStorage('evicted')} - {evicted.lost > 0 && <> {tStorage('evicted_lost', { count: evicted.lost })}</>} + {storeReset.lost > 0 && <> {tStorage('evicted_lost', { count: storeReset.lost })}</>} </p> - <Button variant="outline" size="sm" className="shrink-0" onClick={() => dismissEvicted(account)}>{tStorage('evicted_dismiss')}</Button> + <Button variant="outline" size="sm" className="shrink-0" onClick={() => dismissStoreReset(account)}>{tStorage('evicted_dismiss')}</Button> </div> )} {enabled && ( @@ -169,7 +169,7 @@ export function AccountStorage({ account, primary, heading }: { account: string; <div className="flex flex-col items-start gap-1 sm:items-end"> <Select value={String(currentCap)} - onChange={(value) => chooseScope(account, { maxAttachmentMb: Number(value) })} + onChange={(value) => chooseScope(account, { maxAttachmentBytes: Number(value) * MB })} options={(caps.includes(currentCap) ? caps : [...caps, currentCap].sort((a, b) => a - b)).map((mb) => ({ value: String(mb), label: formatFileSize(mb * MB) }))} /> {locked.maxAttachment && caps.length > 1 && <OrgLockedNote />} diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx index b7657ce4d..5e4f7cffd 100644 --- a/components/offline/offline-host.tsx +++ b/components/offline/offline-host.tsx @@ -8,8 +8,7 @@ import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; import { addSignOutGate } from '@/lib/sign-out-cleanup'; import { reloadToNewBuild } from '@/lib/app-update'; import { buildSettingsPath } from '@/lib/deep-links'; -import { confirmLoss, setOfflineNotifier, startOfflineMode, type OfflineNotice } from '@/lib/offline/offline-mode'; -import { useOfflineStore } from '@/lib/offline/state'; +import { confirmLoss, setOfflineNotifier, startOfflineMode, unsentCount, type OfflineNotice } from '@/lib/offline/offline-mode'; import { toast } from '@/stores/toast-store'; /** @@ -63,15 +62,21 @@ export function OfflineHost() { case 'storage-failed': toast.warning(tStorageRef.current('storage_full_title'), { message: tStorageRef.current('storage_full_text'), duration: 0, action: openStorage }); break; - case 'evicted': - toast.warning(tStorageRef.current('evicted'), { + case 'default-on': + toast.info(tStorageRef.current('default_on'), { duration: 0, action: openStorage }); + break; + case 'legacy-stranded': + toast.warning(tConflictsRef.current('legacy_stranded'), { duration: 0 }); + break; + case 'store-reset': + toast.warning(tConflictsRef.current('store_reset'), { message: notice.lost > 0 ? tStorageRef.current('evicted_lost', { count: notice.lost }) : undefined, - duration: notice.lost > 0 ? 0 : undefined, + duration: 0, action: openStorage, }); break; - case 'default-on': - toast.info(tStorageRef.current('default_on'), { duration: 0, action: openStorage }); + case 'store-unavailable': + toast.warning(tConflictsRef.current('store_unavailable'), { duration: 0 }); break; case 'refused-delete': toast.warning(tr('toast.refused_delete')); @@ -94,12 +99,16 @@ export function OfflineHost() { const stop = startOfflineMode(); // Messages that would be lost: ask before a sign-out the user started. const removeGate = addSignOutGate(async (accountId) => { - const unsent = useOfflineStore.getState().outbox.filter((row) => accountId === 'all' || row.account === accountId).length; - if (unsent === 0) return true; + const { messages, changes } = await unsentCount(accountId); + if (messages === 0 && changes === 0) return true; const tr = tRef.current; + const trConflicts = tConflictsRef.current; const ok = await confirmRef.current({ - title: tr('signout.title'), - message: tr('signout.message', { count: unsent }), + title: messages > 0 ? tr('signout.title') : trConflicts('signout_changes_title'), + message: [ + ...(messages > 0 ? [tr('signout.message', { count: messages })] : []), + ...(changes > 0 ? [trConflicts('signout_changes', { count: changes })] : []), + ].join(' '), confirmText: tr('signout.confirm'), variant: 'destructive', }); diff --git a/components/offline/offline-settings.tsx b/components/offline/offline-settings.tsx index 6205828d0..2dfea8a3a 100644 --- a/components/offline/offline-settings.tsx +++ b/components/offline/offline-settings.tsx @@ -31,6 +31,8 @@ export function OfflineSettings() { const labels = useAccountStore((s) => s.accounts); const enabledCount = useOfflineStore((s) => s.enabled.length); const persist = useOfflineStore((s) => s.persist); + const blocked = useOfflineStore((s) => s.storageBlocked); + const tNotes = useTranslations('offlineConflicts'); const [asking, setAsking] = useState(false); useEffect(() => { @@ -51,7 +53,9 @@ export function OfflineSettings() { return ( <SettingsSection id="offline" title={t('title')} description={t('description')}> - {!offlineSupported() ? ( + {blocked ? ( + <p className="text-sm text-muted-foreground" data-testid="offline-private">{tNotes('store_private')}</p> + ) : !offlineSupported() ? ( <p className="text-sm text-muted-foreground" data-testid="offline-unsupported">{t('unsupported')}</p> ) : ( <> diff --git a/components/plugins/plugin-page-app.tsx b/components/plugins/plugin-page-app.tsx index 3d2be1b96..f2fad0c8d 100644 --- a/components/plugins/plugin-page-app.tsx +++ b/components/plugins/plugin-page-app.tsx @@ -133,7 +133,7 @@ export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) { } return ( - <div className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}> + <div className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}> <AppTopBannerSlot /> {mvShell && ( <MvTopBar diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx new file mode 100644 index 000000000..3ff5cf883 --- /dev/null +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -0,0 +1,144 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel'; +import { openPopoutChannel, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; +import { createComposeHandoff, rememberComposePayload } from '@/lib/popout/compose-handoff'; +import { useAuthStore } from '@/stores/auth-store'; +import { usePluginStore } from '@/stores/plugin-store'; + +// The composer's window: when it tells the opening window to let go, and what +// happens on a reload. The composer itself is stubbed; its own tests cover it. + +type ComposerProps = { + initialData?: { draftId?: string | null; subject?: string; unsaved?: boolean } | null; + ownWindow: { rescueDraft: (draft: RescuedDraft) => boolean }; +}; +let composer: ComposerProps | null; + +vi.mock('@/components/email/email-composer', () => ({ + EmailComposer: (props: ComposerProps) => { + composer = props; + return <div data-testid="composer">{props.initialData?.subject}</div>; + }, +})); +vi.mock('@/components/error', () => ({ ErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}</>, ComposerErrorFallback: () => null })); +vi.mock('../popout-shell', () => ({ + PopoutShell: ({ children }: { children: React.ReactNode }) => <>{children}</>, + PopoutSkeleton: () => <div data-testid="skeleton" />, +})); +vi.mock('@/hooks/use-document-title', () => ({ useDocumentTitle: vi.fn() })); + +const { PopoutComposeApp } = await import('../popout-compose-app'); + +const original = useAuthStore.getState(); +const PAYLOAD = { mode: 'compose' as const, initialData: { subject: 'Moving', draftId: 'old', unsaved: true } as never }; +let heard: PopoutMessage[]; +let listener: NonNullable<ReturnType<typeof openPopoutChannel>>; + +const settle = async (ms = 10) => { + for (let round = 0; round < 5; round++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }); + } +}; + +beforeEach(() => { + FakeBroadcastChannel.reset(); + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + sessionStorage.clear(); + composer = null; + heard = []; + listener = openPopoutChannel((message) => heard.push(message))!; + useAuthStore.setState({ client: {} as never, activeAccountId: 'alice' }); +}); + +afterEach(() => { + cleanup(); + listener.close(); + useAuthStore.setState(original); + vi.unstubAllGlobals(); + vi.resetModules(); + window.history.replaceState(null, '', '/'); +}); + +function openAt(handoffId: string) { + window.history.replaceState(null, '', `/en/popout/compose?handoff=${handoffId}&account=alice`); + render(<PopoutComposeApp />); +} + +describe('PopoutComposeApp', () => { + it('tells the opening window to let go only with the composer on screen', async () => { + const handoff = createComposeHandoff()!; + openAt(handoff.id); + await settle(); + expect(heard.some((message) => message.type === 'handoff-rendered')).toBe(false); + expect(screen.queryByTestId('composer')).toBeNull(); + + await expect(Promise.all([handoff.complete(PAYLOAD), settle()]).then(([shown]) => shown)).resolves.toBe(true); + expect(screen.getByTestId('composer').textContent).toBe('Moving'); + }); + + it('does not while it has no session, so the opening window keeps its copy', async () => { + useAuthStore.setState({ client: null }); + const handoff = createComposeHandoff()!; + openAt(handoff.id); + await settle(); + const shown = handoff.complete(PAYLOAD, 100); + await settle(30); + await expect(shown).resolves.toBe(false); + handoff.cancel(); + await settle(); + }); + + it('closes when the opening window takes the draft back, also after it was delivered', async () => { + const close = vi.spyOn(window, 'close').mockImplementation(() => {}); + useAuthStore.setState({ client: null }); + const handoff = createComposeHandoff()!; + openAt(handoff.id); + await settle(); + void handoff.complete(PAYLOAD, 5000); + await settle(); + expect(screen.getByTestId('composer')).toBeTruthy(); + handoff.cancel(); + await settle(); + expect(screen.queryByTestId('composer')).toBeNull(); + expect(close).toHaveBeenCalled(); + close.mockRestore(); + }); + + it('after a reload, takes up the id its text was saved under, so no second draft appears', async () => { + rememberComposePayload('h1', PAYLOAD); + const main = openPopoutChannel((message) => { + if (message.type === 'rescue-query') main.post({ type: 'rescue-answer', rescueKey: message.rescueKey, draftId: 'saved-by-main' }); + })!; + openAt('h1'); + await waitFor(() => expect(composer?.initialData?.draftId).toBe('saved-by-main')); + expect(composer?.initialData?.unsaved).toBe(false); + main.close(); + }); + + it('after a reload with nothing handed over, keeps what it holds', async () => { + rememberComposePayload('h2', PAYLOAD); + openAt('h2'); + await waitFor(() => expect(composer?.initialData?.draftId).toBe('old'), { timeout: 4000 }); + expect(composer?.initialData?.unsaved).toBe(true); + }); + + it('names the login and the key when it hands over a closing draft', async () => { + const main = openPopoutChannel((message) => { + if (message.type === 'main-ping') main.post({ type: 'main-pong', windowId: 'w', accountIds: ['alice'] }); + })!; + rememberComposePayload('h3', { mode: 'compose' }); + openAt('h3'); + await waitFor(() => expect(composer).not.toBeNull()); + await settle(); + usePluginStore.setState({ initialized: false }); + expect(composer!.ownWindow.rescueDraft({ state: {} } as RescuedDraft), 'before plugins have started').toBe(false); + usePluginStore.setState({ initialized: true }); + expect(composer!.ownWindow.rescueDraft({ state: {} } as RescuedDraft)).toBe(true); + await settle(); + expect(heard.find((message) => message.type === 'draft-rescue')).toMatchObject({ draft: { localAccountId: 'alice', rescueKey: 'h3' } }); + main.close(); + }); +}); diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx new file mode 100644 index 000000000..4defd25c8 --- /dev/null +++ b/components/popout/__tests__/popout-host.test.tsx @@ -0,0 +1,319 @@ +import { act, cleanup, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel'; +import { openPopoutChannel, requestWindowSession, watchMainWindow, askRescuedDraftId, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; +import { takeRescuedDraft } from '@/lib/popout/rescued-drafts'; +import { createSessionOpener } from '@/lib/popout/sealed'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useToastStore } from '@/stores/toast-store'; +import { PopoutHost } from '../popout-host'; + +// The main window's side of pop-outs. The next-intl mock returns the key. + +const DRAFT: RescuedDraft = { + localAccountId: 'alice', + to: ['a@example.test'], subject: 'Closing', body: 'text', cc: [], bcc: [], draftId: 'old', attachments: [], + state: { subject: 'Closing' } as never, +}; + +const original = { auth: useAuthStore.getState(), email: useEmailStore.getState(), accounts: useAccountStore.getState() }; +let heard: PopoutMessage[]; +let popout: NonNullable<ReturnType<typeof openPopoutChannel>>; +let client: { createDraft: ReturnType<typeof vi.fn>; getAuthHeader: () => string; isOfflineOnly: () => boolean }; +let lockRequests: string[]; + +// Rounds, not one long wait: a message and its answer are two hops, and a busy +// machine can run a single timer before the second hop is even queued. +const settle = async (ms = 10) => { + for (let round = 0; round < 5; round++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }); + } +}; + +/** Web Locks with one holder per name, as a browser has. */ +function stubLocks() { + const held = new Set<string>(); + lockRequests = []; + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { + request(name: string, options: { ifAvailable?: boolean }, callback: (lock: unknown) => Promise<unknown>) { + lockRequests.push(name); + if (options.ifAvailable && held.has(name)) return Promise.resolve(callback(null)); + held.add(name); + return Promise.resolve(callback({})); + }, + }, + }); +} + +beforeEach(() => { + FakeBroadcastChannel.reset(); + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + // The bridge makes pop-outs available without stubbing the display mode. + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: {} }; + stubLocks(); + sessionStorage.clear(); + heard = []; + popout = openPopoutChannel((message) => heard.push(message))!; + client = { createDraft: vi.fn(async () => 'new'), getAuthHeader: () => 'Bearer main', isOfflineOnly: () => false }; + useAccountStore.setState({ + accounts: [{ id: 'alice', serverUrl: 'https://mail.example.test', username: 'alice', authMode: 'oauth', cookieSlot: 0 } as never], + activeAccountId: 'alice', + }); + useAuthStore.setState({ + isAuthenticated: true, + isDemoMode: false, + activeAccountId: 'alice', + client: client as never, + getAllConnectedClients: () => new Map([['alice', client]]) as never, + getClientForAccount: (id: string) => (id === 'alice' ? client : undefined) as never, + }); + useEmailStore.setState({ refreshCurrentMailbox: vi.fn(async () => {}) as never, fetchMailboxes: vi.fn(async () => {}) as never, pendingUndoSend: null }); + useToastStore.getState().clearToasts(); +}); + +afterEach(() => { + cleanup(); + popout.close(); + delete window.bulwarkDesktop; + useAuthStore.setState(original.auth); + useEmailStore.setState(original.email); + useAccountStore.setState(original.accounts); + vi.unstubAllGlobals(); + Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined }); +}); + +const answers = () => heard.filter((message) => message.type === 'session-answer'); + +describe('in an ordinary browser tab', () => { + it('runs nothing: no channel and no lock', async () => { + delete window.bulwarkDesktop; + const opened = FakeBroadcastChannel.open.size; + render(<PopoutHost />); + await settle(); + expect(FakeBroadcastChannel.open.size).toBe(opened); + expect(lockRequests).toEqual([]); + popout.post({ type: 'main-ping' }); + await settle(); + expect(heard).toEqual([]); + }); +}); + +describe('PopoutHost', () => { + it('holds no lock while idle, which would keep the page out of the back/forward cache', async () => { + render(<PopoutHost />); + await settle(); + expect(lockRequests).toEqual([]); + }); + + it('lends the session to a pop-out that asks, sealed', async () => { + render(<PopoutHost />); + await settle(); + await expect(requestWindowSession({}, 2000)).resolves.toMatchObject({ + activeAccountId: 'alice', + sessions: [{ accountId: 'alice', authHeader: 'Bearer main', authMode: 'oauth' }], + }); + expect(JSON.stringify(heard)).not.toContain('Bearer main'); + }); + + it('says nothing while it has no session to lend, so a pop-out goes on to the cookies', async () => { + useAuthStore.setState({ isAuthenticated: false }); + render(<PopoutHost />); + await settle(); + await expect(requestWindowSession({}, 100)).resolves.toBeNull(); + }); + + it('says nothing about a login it does not hold, so the window that holds it answers first', async () => { + render(<PopoutHost />); + await settle(); + await expect(requestWindowSession({ accountId: 'bob' }, 100)).resolves.toBeNull(); + expect(answers()).toEqual([]); + }); + + it('renews a token the server refused, once, in this window', async () => { + const refresh = vi.fn(async () => 'next'); + useAuthStore.setState({ refreshAccessToken: refresh as never }); + render(<PopoutHost />); + await settle(); + await requestWindowSession({ accountId: 'alice', rejectedAuthHeader: 'Bearer main' }, 2000); + expect(refresh).toHaveBeenCalledWith({ accountId: 'alice' }); + refresh.mockClear(); + await requestWindowSession({ accountId: 'alice', rejectedAuthHeader: 'Bearer older' }, 2000); + expect(refresh).not.toHaveBeenCalled(); + }); + + it('saves a closing window\'s draft, replacing its version', async () => { + render(<PopoutHost />); + await settle(); + popout.post({ type: 'draft-rescue', draft: DRAFT }); + await settle(); + expect(client.createDraft).toHaveBeenCalledTimes(1); + expect(client.createDraft.mock.calls[0].slice(0, 2)).toEqual([['a@example.test'], 'Closing']); + expect(client.createDraft.mock.calls[0][7]).toBe('old'); + }); + + it('saves it once with two main windows open', async () => { + render(<><PopoutHost /><PopoutHost /></>); + await settle(); + popout.post({ type: 'draft-rescue', draft: { ...DRAFT, rescueKey: 'k' } }); + await settle(); + expect(client.createDraft).toHaveBeenCalledTimes(1); + }); + + it('keeps the text for the "Continue draft" slot when the save fails, and says so', async () => { + client.createDraft.mockRejectedValueOnce(new Error('Server down')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + render(<PopoutHost />); + await settle(); + popout.post({ type: 'draft-rescue', draft: DRAFT }); + await settle(); + expect(useToastStore.getState().toasts.map((toast) => toast.type)).toEqual(['error']); + expect(takeRescuedDraft()).toMatchObject({ subject: 'Closing', unsaved: true }); + }); + + it('keeps the text too when the login is gone by then', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + useAuthStore.setState({ getClientForAccount: () => undefined }); + render(<PopoutHost />); + await settle(); + popout.post({ type: 'draft-rescue', draft: DRAFT }); + await settle(); + expect(takeRescuedDraft()).toMatchObject({ subject: 'Closing' }); + }); + + it('tells a reloaded pop-out which id its text was saved under', async () => { + render(<PopoutHost />); + await settle(); + popout.post({ type: 'draft-rescue', draft: { ...DRAFT, rescueKey: 'k1' } }); + await settle(); + await expect(askRescuedDraftId('k1', 1000)).resolves.toBe('new'); + await expect(askRescuedDraftId('nobody', 50)).resolves.toBeNull(); + }); + + it('takes over the send\'s outcome once, and offers Undo here', async () => { + render(<><PopoutHost /><PopoutHost /></>); + await settle(); + const pending = { submissionId: 's', sendAt: '2030-01-01T00:00:00Z', isSmime: false }; + popout.post({ type: 'sent', requestId: 'r1', notice: { accountId: 'alice', pendingUndoSend: pending } }); + await settle(); + expect(heard.filter((message) => message.type === 'sent-ack')).toHaveLength(1); + expect(useEmailStore.getState().pendingUndoSend).toEqual(pending); + expect(useToastStore.getState().toasts).toEqual([]); + + useEmailStore.setState({ pendingUndoSend: null }); + popout.post({ type: 'sent', requestId: 'r2', notice: { accountId: 'alice' } }); + await settle(); + expect(useToastStore.getState().toasts).toHaveLength(1); + }); + + it('refreshes the list and counts when a pop-out changed mail, once for a burst', async () => { + render(<PopoutHost />); + await settle(); + popout.post({ type: 'mail-changed', accountId: 'alice' }); + popout.post({ type: 'mail-changed', accountId: 'alice' }); + await settle(400); + expect(useEmailStore.getState().refreshCurrentMailbox).toHaveBeenCalledTimes(1); + expect(useEmailStore.getState().fetchMailboxes).toHaveBeenCalledTimes(1); + }); + + it('tells pop-outs when its own list changed, since no push reports that to them', async () => { + render(<PopoutHost />); + await settle(); + act(() => useEmailStore.setState({ emails: [{ id: 'x' } as never] })); + await settle(120); + expect(heard).toContainEqual({ type: 'state-change' }); + }); + + it('tells pop-outs when an account went away', async () => { + render(<PopoutHost />); + await settle(); + act(() => useAccountStore.setState({ accounts: [] })); + await settle(); + expect(heard).toContainEqual({ type: 'account-removed', accountId: 'alice' }); + }); +}); + +describe('watchMainWindow', () => { + it('knows a main window is there, and hands it the draft', async () => { + render(<PopoutHost />); + await settle(); + const main = watchMainWindow(); + await settle(); + expect(main.alive()).toBe(true); + expect(main.rescue(DRAFT)).toBe(true); + await settle(); + expect(client.createDraft).toHaveBeenCalledTimes(1); + main.stop(); + }); + + it('won\'t hand a draft to a window that doesn\'t hold its login', async () => { + render(<PopoutHost />); + await settle(); + const main = watchMainWindow(); + await settle(); + expect(main.alive('alice')).toBe(true); + expect(main.alive('bob')).toBe(false); + expect(main.rescue({ ...DRAFT, localAccountId: 'bob' })).toBe(false); + await settle(); + expect(client.createDraft).not.toHaveBeenCalled(); + main.stop(); + }); + + it('knows at once when the last one closed, and takes nothing then', async () => { + render(<PopoutHost />); + await settle(); + const main = watchMainWindow(); + await settle(); + await act(async () => { window.dispatchEvent(new Event('pagehide')); }); + cleanup(); + await settle(); + expect(main.alive()).toBe(false); + expect(main.rescue(DRAFT)).toBe(false); + expect(client.createDraft).not.toHaveBeenCalled(); + main.stop(); + }); + + it('is not fooled by a closing window that still runs long enough to hear the ping', async () => { + render(<PopoutHost />); + await settle(); + const main = watchMainWindow(); + await settle(); + // Still mounted: the page is on its way out, its scripts haven't stopped. + await act(async () => { window.dispatchEvent(new Event('pagehide')); }); + const late = openPopoutChannel(() => {})!; + late.post({ type: 'main-ping' }); + await settle(); + late.close(); + expect(main.alive()).toBe(false); + expect(main.rescue(DRAFT)).toBe(false); + main.stop(); + }); + + it('takes nothing with no main window from the start', async () => { + const main = watchMainWindow(); + await settle(); + expect(main.rescue(DRAFT)).toBe(false); + main.stop(); + }); +}); + +describe('sealed answers', () => { + it('open only for the window that asked', async () => { + render(<PopoutHost />); + await settle(); + const other = await createSessionOpener(); + const mine = await createSessionOpener(); + popout.post({ type: 'session-request', requestId: 'r', publicKey: mine.publicKey }); + await settle(); + const [answer] = answers(); + expect(answer).toBeTruthy(); + if (answer.type !== 'session-answer') throw new Error('unreachable'); + await expect(mine.open(answer.sealed)).resolves.toMatchObject({ activeAccountId: 'alice' }); + await expect(other.open(answer.sealed)).rejects.toThrow(); + }); +}); diff --git a/components/popout/__tests__/popout-message-app.test.tsx b/components/popout/__tests__/popout-message-app.test.tsx new file mode 100644 index 000000000..8d877a955 --- /dev/null +++ b/components/popout/__tests__/popout-message-app.test.tsx @@ -0,0 +1,183 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email } from '@/lib/jmap/types'; +import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel'; +import { openPopoutChannel } from '@/lib/popout/channel'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; + +// The pop-out message around the view it hosts: the view is stubbed, since the +// Pro tab's tests cover it. The next-intl mock returns the key. + +type ViewProps = { + emailId: string; + clientAccountId?: string; + accountId?: string; + liveState?: { keywords?: Record<string, boolean>; mailboxIds?: Record<string, boolean> } | null; + onLoaded?: (email: Email) => void; + onClose: () => void; + onOpenCompose?: (data: { mode: string }) => void; + onMailChanged?: () => void; +}; +let view: ViewProps; + +vi.mock('@/components/pro/pro-email-tab-body', () => ({ + ProEmailView: (props: ViewProps) => { + view = props; + return <div data-testid="message-view" />; + }, +})); +vi.mock('../popout-shell', () => ({ + PopoutShell: ({ children }: { children: React.ReactNode }) => <>{children}</>, + PopoutNotice: ({ title, children }: { title: string; children: React.ReactNode }) => <div><h1>{title}</h1>{children}</div>, + PopoutSkeleton: () => <div data-testid="skeleton" />, +})); +vi.mock('@/hooks/use-document-title', () => ({ useDocumentTitle: vi.fn() })); + +const { PopoutMessageApp } = await import('../popout-message-app'); + +const original = { auth: useAuthStore.getState(), email: useEmailStore.getState() }; +const EMAIL = { id: 'm/1', subject: 'Hello', keywords: { $seen: true }, mailboxIds: { inbox: true } } as unknown as Email; +let found: Array<Partial<Email>> | Error; +let client: { getSomeEmails: ReturnType<typeof vi.fn> }; + +const settle = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + }); +}; + +beforeEach(() => { + FakeBroadcastChannel.reset(); + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + window.history.replaceState(null, '', '/en/popout/message/m%2F1?account=login-b&owner=group'); + found = [EMAIL]; + client = { + getSomeEmails: vi.fn(async () => { + if (found instanceof Error) throw found; + return found; + }), + }; + useAuthStore.setState({ + client: client as never, + activeAccountId: 'login-a', + getClientForAccount: (id: string) => (id === 'login-b' ? client : undefined) as never, + }); + useEmailStore.setState({ + emails: [], + mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox' }, { id: 'archive', name: 'Old mail', role: null }] as never, + fetchMailboxes: vi.fn(async () => {}) as never, + }); +}); + +afterEach(() => { + cleanup(); + useAuthStore.setState(original.auth); + useEmailStore.setState(original.email); + vi.unstubAllGlobals(); + window.history.replaceState(null, '', '/'); +}); + +describe('PopoutMessageApp', () => { + it('opens the message of the address, through its login and account', async () => { + render(<PopoutMessageApp id="rendered-for-another-id" />); + await settle(); + expect(view.emailId).toBe('m/1'); + expect(view.clientAccountId).toBe('login-b'); + expect(view.accountId).toBe('group'); + expect(client.getSomeEmails).toHaveBeenCalledWith(['m/1'], 'group'); + }); + + it('shows the message once the folders are in: Delete needs Trash, and the toolbar must not move', async () => { + let finish: () => void = () => {}; + useEmailStore.setState({ mailboxes: [], fetchMailboxes: vi.fn(() => new Promise<void>((resolve) => { finish = resolve; })) as never }); + render(<PopoutMessageApp id="m1" />); + await settle(); + expect(screen.queryByTestId('message-view')).toBeNull(); + expect(screen.getByTestId('skeleton')).toBeTruthy(); + await act(async () => { finish(); }); + await settle(); + expect(screen.getByTestId('message-view')).toBeTruthy(); + }); + + it('shows the message anyway when the folders cannot be fetched', async () => { + useEmailStore.setState({ mailboxes: [], fetchMailboxes: vi.fn(async () => { throw new Error('offline'); }) as never }); + render(<PopoutMessageApp id="m1" />); + await settle(); + expect(screen.getByTestId('message-view')).toBeTruthy(); + }); + + it('puts the loaded message where delete, move, star and read look for it', async () => { + render(<PopoutMessageApp id="m1" />); + await settle(); + act(() => view.onLoaded?.(EMAIL)); + expect(useEmailStore.getState().emails).toEqual([EMAIL]); + expect(useEmailStore.getState().selectedMailbox).toBe('inbox'); + }); + + it('says so when the message is gone', async () => { + found = []; + render(<PopoutMessageApp id="m1" />); + await screen.findByText('message_gone_title'); + expect(screen.queryByTestId('message-view')).toBeNull(); + }); + + it('keeps the message on screen when the server does not answer', async () => { + found = new TypeError('Failed to fetch'); + render(<PopoutMessageApp id="m1" />); + await settle(); + expect(screen.getByTestId('message-view')).toBeTruthy(); + expect(screen.queryByText('message_gone_title')).toBeNull(); + }); + + it('checks again when another window reports a change, and shows the new flags', async () => { + const main = openPopoutChannel(() => {})!; + render(<PopoutMessageApp id="m1" />); + await settle(); + act(() => view.onLoaded?.(EMAIL)); + found = [{ ...EMAIL, keywords: { $seen: false } }]; + main.post({ type: 'state-change' }); + await waitFor(() => expect(view.liveState?.keywords).toEqual({ $seen: false })); + // And the next toggle starts from them. + expect(useEmailStore.getState().emails[0].keywords).toEqual({ $seen: false }); + main.close(); + }); + + it('checks again when the window gets the focus', async () => { + render(<PopoutMessageApp id="m1" />); + await settle(); + found = []; + act(() => { window.dispatchEvent(new Event('focus')); }); + await screen.findByText('message_gone_title'); + }); + + it('says where a moved message went, and keeps showing it', async () => { + render(<PopoutMessageApp id="m1" />); + await settle(); + expect(document.querySelector('[data-popout-moved]')).toBeNull(); + found = [{ ...EMAIL, mailboxIds: { archive: true } }]; + act(() => { window.dispatchEvent(new Event('focus')); }); + await waitFor(() => expect(document.querySelector('[data-popout-moved]')?.textContent).toContain('message_moved_to')); + expect(screen.getByTestId('message-view')).toBeTruthy(); + }); + + it('tells the main window when it changed something', async () => { + const heard: string[] = []; + const main = openPopoutChannel((message) => heard.push(message.type))!; + render(<PopoutMessageApp id="m1" />); + await settle(); + act(() => view.onMailChanged?.()); + await settle(); + expect(heard).toContain('mail-changed'); + main.close(); + }); + + it('closes the window when the message leaves it', async () => { + const close = vi.spyOn(window, 'close').mockImplementation(() => {}); + render(<PopoutMessageApp id="m1" />); + await settle(); + act(() => view.onClose()); + expect(close).toHaveBeenCalledTimes(1); + close.mockRestore(); + }); +}); diff --git a/components/popout/__tests__/popout-shell.test.tsx b/components/popout/__tests__/popout-shell.test.tsx new file mode 100644 index 000000000..ef163dd05 --- /dev/null +++ b/components/popout/__tests__/popout-shell.test.tsx @@ -0,0 +1,156 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { useEffect } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel'; +import { registerComposeFlush } from '@/lib/compose-flush-registry'; +import { SIGNED_OUT_KEY } from '@/lib/sign-out-cleanup'; +import { openPopoutChannel } from '@/lib/popout/channel'; +import { sealFor } from '@/lib/popout/sealed'; +import { SHARED_SESSION_ENDED_EVENT, useAuthStore } from '@/stores/auth-store'; +import { PopoutShell } from '../popout-shell'; + +// How a pop-out's session ends. The next-intl mock returns the key. + +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); + +const original = useAuthStore.getState(); +let close: ReturnType<typeof vi.spyOn>; +let mounts: number; + +function Child() { + useEffect(() => { + mounts += 1; + }, []); + return <div data-testid="content">Typed text</div>; +} + +const settle = async (ms = 10) => { + for (let round = 0; round < 5; round++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }); + } +}; + +async function mount(kind: 'message' | 'compose', session: 'ready' | 'no-session' = 'ready') { + useAuthStore.setState({ startSharedSession: vi.fn(async () => session) as never, activeAccountId: 'alice' }); + render(<PopoutShell kind={kind}><Child /></PopoutShell>); + await settle(); +} + +function endSession() { + act(() => { window.dispatchEvent(new Event(SHARED_SESSION_ENDED_EVENT)); }); +} + +function signOutElsewhere() { + act(() => { window.dispatchEvent(new StorageEvent('storage', { key: SIGNED_OUT_KEY, newValue: String(Date.now()) })); }); +} + +beforeEach(() => { + FakeBroadcastChannel.reset(); + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + close = vi.spyOn(window, 'close').mockImplementation(() => {}); + mounts = 0; +}); + +afterEach(() => { + cleanup(); + useAuthStore.setState(original); + close.mockRestore(); + vi.unstubAllGlobals(); +}); + +describe('when the session is refused', () => { + it('stops showing a message', async () => { + await mount('message'); + expect(screen.getByTestId('content')).toBeTruthy(); + endSession(); + expect(screen.queryByTestId('content')).toBeNull(); + expect(screen.getByText('session_ended_title')).toBeTruthy(); + }); + + it('keeps a composer on screen, the same one, with the text in it', async () => { + await mount('compose'); + endSession(); + expect(screen.getByTestId('content')).toBeTruthy(); + expect(mounts).toBe(1); + expect(document.querySelector('[data-popout-session-ended]')).toBeTruthy(); + }); +}); + +describe('with no session at all', () => { + it('shows a composer that holds text, and says why it can\'t send', async () => { + await mount('compose', 'no-session'); + expect(screen.getByTestId('content')).toBeTruthy(); + expect(document.querySelector('[data-popout-session-ended]')).toBeTruthy(); + }); + + it('shows no message', async () => { + await mount('message', 'no-session'); + expect(screen.queryByTestId('content')).toBeNull(); + }); +}); + +describe('when another window signs everyone out', () => { + it('saves the typing first, then closes', async () => { + const order: string[] = []; + const stop = registerComposeFlush(async () => { order.push('saved'); }); + close.mockImplementation(() => { order.push('closed'); }); + await mount('compose'); + signOutElsewhere(); + await settle(); + expect(order).toEqual(['saved', 'closed']); + stop(); + }); + + it('stays open when the typing could not be saved', async () => { + const stop = registerComposeFlush(async () => { throw new Error('The draft was not saved'); }); + await mount('compose'); + signOutElsewhere(); + await settle(); + expect(close).not.toHaveBeenCalled(); + expect(screen.getByTestId('content')).toBeTruthy(); + expect(document.querySelector('[data-popout-session-ended]')).toBeTruthy(); + stop(); + }); + + it('closes a message window', async () => { + await mount('message'); + signOutElsewhere(); + await settle(); + expect(close).toHaveBeenCalled(); + }); +}); + +describe('when a login leaves the list', () => { + it('closes when no window holds it any more', async () => { + const main = openPopoutChannel(() => {})!; + await mount('message'); + main.post({ type: 'account-removed', accountId: 'alice' }); + await waitFor(() => expect(close).toHaveBeenCalled(), { timeout: 3000 }); + main.close(); + }); + + it('stays when another tab still holds it: a new tab dropped a login it could not restore', async () => { + const main = openPopoutChannel((message) => { + if (message.type !== 'session-request') return; + void sealFor(message.publicKey, { activeAccountId: 'alice', sessions: [{ accountId: 'alice' }] }) + .then((sealed) => main.post({ type: 'session-answer', requestId: message.requestId, sealed })); + })!; + await mount('message'); + main.post({ type: 'account-removed', accountId: 'alice' }); + await settle(250); + expect(close).not.toHaveBeenCalled(); + expect(screen.getByTestId('content')).toBeTruthy(); + main.close(); + }); + + it('ignores another login', async () => { + const main = openPopoutChannel(() => {})!; + await mount('message'); + main.post({ type: 'account-removed', accountId: 'bob' }); + await settle(250); + expect(close).not.toHaveBeenCalled(); + main.close(); + }); +}); diff --git a/components/popout/main-window-only.tsx b/components/popout/main-window-only.tsx new file mode 100644 index 000000000..c518ce99a --- /dev/null +++ b/components/popout/main-window-only.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { isPopoutPath } from "@/lib/popout/window-role"; + +/** + * What belongs to the app as a whole and not to each of its windows: live + * push streams, install and update prompts, the command palette's jumps to + * other surfaces. A pop-out shows one message or one draft, and the main + * window does the rest. + */ +export function MainWindowOnly({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + return isPopoutPath(pathname ?? "") ? null : <>{children}</>; +} diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx new file mode 100644 index 000000000..6f0bebeff --- /dev/null +++ b/components/popout/popout-compose-app.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer"; +import { ErrorBoundary, ComposerErrorFallback } from "@/components/error"; +import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { usePluginStore } from "@/stores/plugin-store"; +import { toast } from "@/stores/toast-store"; +import { formatRejectedRecipients } from "@/lib/jmap/client"; +import { debug } from "@/lib/debug"; +import { composerSendOptions } from "@/lib/outgoing/composer-send-options"; +import { announceSent } from "@/lib/plugin-after-send"; +import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type RescuedDraft } from "@/lib/popout/channel"; +import { acknowledgeComposeHandoff, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff"; +import type { ComposePopoutPayload } from "@/lib/popout/compose-payload"; +import { closePopoutWindow } from "@/lib/popout/window-role"; +import { PopoutShell, PopoutSkeleton } from "./popout-shell"; + +/** A warning with no main window left to show it stays this long before the window goes. */ +const WARNING_READ_MS = 8000; + +type ComposerProps = React.ComponentProps<typeof EmailComposer>; +type SendData = Parameters<NonNullable<ComposerProps["onSend"]>>[0]; + +const EMPTY_PAYLOAD: ComposePopoutPayload = { mode: "compose" }; + +/** How long a reloaded window waits to hear what became of the text it handed over on the way out. */ +const RESCUE_ANSWER_MS = 2000; + +function readLaunch(): { handoffId: string | null; accountId?: string } { + if (typeof window === "undefined") return { handoffId: null }; + const search = new URLSearchParams(window.location.search); + return { handoffId: search.get("handoff"), accountId: search.get("account") ?? undefined }; +} + +/** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */ +function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; handoffId: string | null }) { + const t = useTranslations(); + const client = useAuthStore((s) => s.client); + const [sessionKey] = useState(() => `popout-${handoffId ?? Date.now().toString(36)}`); + + const mode = payload.initialData?.mode ?? payload.mode; + useDocumentTitle( + mode === "reply" ? t("email_composer.reply") + : mode === "replyAll" ? t("email_composer.reply_all") + : mode === "forward" ? t("email_composer.forward") + : t("email_composer.new_message"), + ); + + const handleSend = useCallback(async (data: SendData) => { + if (!client) return false; + const sendClient = data.localAccountId + ? (useAuthStore.getState().getClientForAccount(data.localAccountId) ?? client) + : client; + const emailStore = useEmailStore.getState(); + // A send that fails goes to the composer, which stays open with the text. + const result = await emailStore.sendEmail( + sendClient, + data.to, + data.subject, + data.body, + data.cc, + data.bcc, + data.identityId, + data.fromEmail, + data.draftId, + data.fromName, + data.htmlBody, + data.attachments, + data.inReplyTo, + data.references, + data.delayedUntil, + data.envelopeMailFrom, + composerSendOptions(data), + ); + announceSent(result, data); + + if (payload.sourceEmailId && (mode === "reply" || mode === "replyAll" || mode === "forward")) { + const keyword = mode === "forward" ? "$forwarded" : "$answered"; + try { + await useEmailStore.getState().markEmailKeyword(client, payload.sourceEmailId, keyword, payload.sourceEmailSource ?? undefined); + } catch (error) { + debug.error(`Failed to set ${keyword} keyword:`, error); + } + } + + const accountId = data.localAccountId ?? useAuthStore.getState().activeAccountId; + const pending = useEmailStore.getState().pendingUndoSend; + const rejectedRecipients = result.rejectedRecipients?.length ? formatRejectedRecipients(result.rejectedRecipients) : undefined; + const taken = await announceSentToMainWindow({ + accountId, + scheduled: !!result.scheduled, + queued: !!result.queued, + filingError: !!result.filingError, + rejectedRecipients, + pendingUndoSend: pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null, + }); + // No main window to say so, and this is the only notice these get (#1123). + if (!taken && (result.filingError || rejectedRecipients)) { + if (result.filingError) toast.warning(t("email_composer.send_filing_warning")); + if (rejectedRecipients) toast.warning(t("email_composer.send_some_recipients_rejected"), { message: rejectedRecipients, duration: WARNING_READ_MS }); + await new Promise((resolve) => setTimeout(resolve, WARNING_READ_MS)); + } + }, [client, mode, payload.sourceEmailId, payload.sourceEmailSource, t]); + + const handleDiscardDraft = useCallback(async (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => { + if (!client) return; + // Through the login the draft was saved with, and never another: the id names another message there. + const draftClient = draft?.localAccountId + ? useAuthStore.getState().getClientForAccount(draft.localAccountId) + : client; + if (!draftClient) { + toast.error(t("email_composer.discard_signed_out")); + return; + } + try { + await draftClient.deleteEmail(draftId, draft?.accountId); + } catch (error) { + console.error("Failed to discard draft:", error); + } + }, [client, t]); + + const handleClose = useCallback(() => { + announceMailChanged(useAuthStore.getState().activeAccountId); + closePopoutWindow(); + }, []); + + const [mainWindow] = useState(watchMainWindow); + useEffect(() => () => mainWindow.stop(), [mainWindow]); + + // The window that opened this one lets go of its copy now. Not without a + // session: this one couldn't save or send the text. + useEffect(() => { + if (handoffId && client) acknowledgeComposeHandoff(handoffId); + }, [handoffId, client]); + + const ownWindow = useMemo(() => ({ + rescueDraft: (draft: RescuedDraft) => { + // A plugin that hasn't started may yet have a say in what a draft holds, + // and it can only have it in this window's save. + if (!usePluginStore.getState().initialized) return false; + return mainWindow.rescue({ + ...draft, + localAccountId: draft.localAccountId ?? useAuthStore.getState().activeAccountId ?? undefined, + rescueKey: handoffId ?? undefined, + }); + }, + // A reload of this window starts from where the text is now. + onUnloadState: (state: ComposerDraftData) => { + if (handoffId) rememberComposePayload(handoffId, { ...payload, initialData: state, prefill: null }); + }, + onDraftSaved: () => announceMailChanged(useAuthStore.getState().activeAccountId), + }), [handoffId, mainWindow, payload]); + + return ( + <ErrorBoundary fallback={ComposerErrorFallback}> + <EmailComposer + mode={mode} + replyTo={payload.replyTo} + initialDraftText={payload.initialDraftText} + initialData={payload.initialData} + prefill={payload.prefill} + sessionKey={sessionKey} + sourceEmailId={payload.sourceEmailId ?? undefined} + composeFromAccountEmail={payload.composeFromAccountEmail + ?? useAccountStore.getState().getAccountById(useAuthStore.getState().activeAccountId ?? "")?.email} + onSend={handleSend} + onClose={handleClose} + onDiscardDraft={handleDiscardDraft} + ownWindow={ownWindow} + className="min-h-0 flex-1" + /> + </ErrorBoundary> + ); +} + +/** `/popout/compose`: a draft handed over by another window, or a new message. */ +export function PopoutComposeApp() { + const t = useTranslations("pwaWindows"); + const [{ handoffId, accountId }] = useState(readLaunch); + const [payload, setPayload] = useState<ComposePopoutPayload | null>(handoffId ? null : EMPTY_PAYLOAD); + + useEffect(() => { + if (!handoffId) return; + let cancelled = false; + const reloaded = rememberedComposePayload(handoffId) !== null; + void receiveComposeHandoff(handoffId).then(async (received) => { + if (!received) { + // Called off or never ready: the composer stayed where it was. + if (!cancelled) closePopoutWindow(); + return; + } + // Reloaded: the text may have gone to a main window on the way out, under + // a new id. Saving under the old one would leave two drafts. + const draftId = reloaded && received.initialData ? await askRescuedDraftId(handoffId, RESCUE_ANSWER_MS) : null; + if (cancelled) return; + setPayload(draftId && received.initialData + ? { ...received, initialData: { ...received.initialData, draftId, draftAccountId: undefined, unsaved: false } } + : received); + }); + // The opening window takes the draft back. Gone first, so that closing + // doesn't save a copy of what that window still has. + const stopWatching = watchHandoffCancel(handoffId, () => { + setPayload(null); + setTimeout(closePopoutWindow, 0); + }); + return () => { + cancelled = true; + stopWatching(); + }; + }, [handoffId]); + + return ( + <PopoutShell kind="compose" accountId={accountId}> + {payload ? <PopoutComposer payload={payload} handoffId={handoffId} /> : <PopoutSkeleton label={t("opening")} />} + </PopoutShell> + ); +} diff --git a/components/popout/popout-host.tsx b/components/popout/popout-host.tsx new file mode 100644 index 000000000..54bf13630 --- /dev/null +++ b/components/popout/popout-host.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { usePopoutsAvailable } from "@/hooks/use-popouts-available"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { toast } from "@/stores/toast-store"; +import { newPopoutId, openPopoutChannel, type PopoutChannel, type PopoutMessage, type PopoutSentNotice, type RescuedDraft, type SharedAccountSession } from "@/lib/popout/channel"; +import { keepRescuedDraft } from "@/lib/popout/rescued-drafts"; +import { credentialFingerprint, sealFor } from "@/lib/popout/sealed"; +import { changesAnyType, subscribePushStateChanges } from "@/lib/push-events"; + +/** At most this often: a pop-out's autosave reports every few seconds. */ +const REFRESH_THROTTLE_MS = 1500; +/** What the list changing in this window waits for, so a burst tells pop-outs once. */ +const LIST_CHANGE_DEBOUNCE_MS = 400; +/** How long the claim on a one-off job stays held: longer than a message takes to reach every window. */ +const CLAIM_HOLD_MS = 15_000; +/** How long a window remembers which draft id a rescue became, for a reload of the pop-out. */ +const RESCUE_MEMORY_MS = 60_000; + +/** The sessions this window can lend. Empty until it is signed in, and in the demo. */ +function shareableSessions(): SharedAccountSession[] { + const auth = useAuthStore.getState(); + if (!auth.isAuthenticated || auth.isDemoMode) return []; + const sessions: SharedAccountSession[] = []; + for (const [accountId, client] of auth.getAllConnectedClients()) { + const account = useAccountStore.getState().getAccountById(accountId); + if (!account) continue; + sessions.push({ + accountId, + serverUrl: account.serverUrl, + username: account.username, + authMode: account.authMode, + authHeader: client.getAuthHeader(), + ...(client.isOfflineOnly() ? { offlineOnly: true } : {}), + }); + } + return sessions; +} + +/** + * Runs `work` in one of the main windows, the first to claim `name`. Held on + * for a while afterwards, or a slower window would find it free. A browser + * releases it with the window, so no window keeps a lock for long (one held for + * life keeps a page out of the back/forward cache). + */ +function claimOnce(name: string, work: () => void | Promise<void>): void { + void navigator.locks?.request(name, { ifAvailable: true }, async (lock) => { + if (!lock) return; + await work(); + await new Promise((resolve) => setTimeout(resolve, CLAIM_HOLD_MS)); + }).catch(() => {}); +} + +/** + * The main window's side of pop-outs: lends them its session and renews it + * for them, passes on that mail changed, and takes over what a pop-out can't + * say any more once it has closed. Only where pop-outs are on offer: an + * ordinary browser tab runs none of this. + */ +export function PopoutHost() { + const available = usePopoutsAvailable(); + const t = useTranslations(); + const tRef = useRef(t); + tRef.current = t; + + useEffect(() => { + if (!available) return; + let channel: PopoutChannel | null = null; + const windowId = newPopoutId(); + let closing = false; + const rescues = new Map<string, Promise<string | null>>(); + + const answerSessionRequest = async (message: Extract<PopoutMessage, { type: "session-request" }>) => { + const { accountId, rejectedFingerprint } = message; + const auth = useAuthStore.getState(); + // Only a window holding the login answers: the first answer has to be usable. + if (accountId && !shareableSessions().some((session) => session.accountId === accountId)) return; + // The server refused what the pop-out holds. If that is what this window + // holds too, renew it here, where the timers and the sign-out live. + if (accountId && rejectedFingerprint) { + const current = auth.getClientForAccount(accountId)?.getAuthHeader(); + if (current && await credentialFingerprint(current) === rejectedFingerprint) await auth.refreshAccessToken({ accountId }); + } + const sessions = shareableSessions(); + if (sessions.length === 0) return; + const sealed = await sealFor(message.publicKey, { activeAccountId: useAuthStore.getState().activeAccountId, sessions }); + channel?.post({ type: "session-answer", requestId: message.requestId, sealed }); + }; + + // List and counts, so read state and Drafts follow what a pop-out did. + let refreshTimer: ReturnType<typeof setTimeout> | null = null; + const refreshList = () => { + if (refreshTimer) return; + refreshTimer = setTimeout(() => { + refreshTimer = null; + const client = useAuthStore.getState().client; + if (!client) return; + const emails = useEmailStore.getState(); + void emails.refreshCurrentMailbox(client); + void emails.fetchMailboxes(client); + }, REFRESH_THROTTLE_MS); + }; + + const takeOverSent = (notice: PopoutSentNotice) => { + const translate = tRef.current; + if (notice.filingError) toast.warning(translate("email_composer.send_filing_warning")); + if (notice.rejectedRecipients) { + toast.warning(translate("email_composer.send_some_recipients_rejected"), { message: notice.rejectedRecipients, duration: 15000 }); + } + // Undo lives here now: the window that sent it is closing. + if (notice.pendingUndoSend) useEmailStore.setState({ pendingUndoSend: notice.pendingUndoSend }); + else if (!notice.queued && !notice.scheduled && !notice.filingError && !notice.rejectedRecipients) toast.success(translate("notifications.email_sent")); + refreshList(); + }; + + const saveRescuedDraft = async (draft: RescuedDraft): Promise<string | null> => { + const auth = useAuthStore.getState(); + const client = draft.localAccountId ? auth.getClientForAccount(draft.localAccountId) : auth.client; + try { + if (!client) throw new Error("No login to save through"); + const draftId = await client.createDraft( + draft.to, draft.subject, draft.body, draft.cc, draft.bcc, draft.identityId, draft.fromEmail, draft.draftId, + draft.attachments, draft.fromName, draft.htmlBody, draft.inReplyTo, draft.references, draft.options, + ); + refreshList(); + return draftId; + } catch (error) { + // The text exists nowhere else now. + console.error("Failed to save a closing window's draft:", error); + keepRescuedDraft(draft.state); + toast.error(tRef.current("email_composer.save_failed")); + return null; + } + }; + + const startRescue = (draft: RescuedDraft) => { + const claim = draft.rescueKey ?? newPopoutId(); + claimOnce(`bulwark-popout-rescue:${claim}`, async () => { + const saved = saveRescuedDraft(draft); + if (draft.rescueKey) { + rescues.set(draft.rescueKey, saved); + setTimeout(() => rescues.delete(claim), RESCUE_MEMORY_MS); + } + await saved; + }); + }; + + channel = openPopoutChannel((message) => { + switch (message.type) { + case "session-request": + void answerSessionRequest(message); + break; + case "sent": + claimOnce(`bulwark-popout-sent:${message.requestId}`, () => { + channel?.post({ type: "sent-ack", requestId: message.requestId }); + takeOverSent(message.notice); + }); + break; + case "draft-rescue": + startRescue(message.draft); + break; + case "rescue-query": { + const rescue = rescues.get(message.rescueKey); + if (rescue) void rescue.then((draftId) => channel?.post({ type: "rescue-answer", rescueKey: message.rescueKey, draftId })); + break; + } + case "main-ping": + if (!closing) channel?.post({ type: "main-pong", windowId, accountIds: shareableSessions().map((session) => session.accountId) }); + break; + case "mail-changed": + refreshList(); + break; + } + }); + if (!channel) return; + + const stopPush = subscribePushStateChanges((change) => { + if (changesAnyType(change, ["Email", "Mailbox"])) channel?.post({ type: "state-change" }); + }); + // This window's own changes reach no push stream on the way to a pop-out. + let listTimer: ReturnType<typeof setTimeout> | null = null; + const stopList = useEmailStore.subscribe((state, prev) => { + if (state.emails === prev.emails && state.selectedEmail === prev.selectedEmail) return; + if (listTimer) clearTimeout(listTimer); + listTimer = setTimeout(() => channel?.post({ type: "state-change" }), LIST_CHANGE_DEBOUNCE_MS); + }); + const stopAccounts = useAccountStore.subscribe((state, prev) => { + if (state.accounts === prev.accounts) return; + const ids = new Set(state.accounts.map((account) => account.id)); + for (const account of prev.accounts) { + if (!ids.has(account.id)) channel?.post({ type: "account-removed", accountId: account.id }); + } + }); + // Hearing a ping late in closing, this window must not answer: a draft + // handed to it now would be lost. + const onPageHide = () => { + closing = true; + channel?.post({ type: "main-gone", windowId }); + }; + const onPageShow = () => { + closing = false; + }; + window.addEventListener("pagehide", onPageHide); + window.addEventListener("pageshow", onPageShow); + return () => { + window.removeEventListener("pagehide", onPageHide); + window.removeEventListener("pageshow", onPageShow); + stopPush(); + stopList(); + stopAccounts(); + if (refreshTimer) clearTimeout(refreshTimer); + if (listTimer) clearTimeout(listTimer); + channel?.close(); + }; + }, [available]); + + return null; +} diff --git a/components/popout/popout-message-app.tsx b/components/popout/popout-message-app.tsx new file mode 100644 index 000000000..c7841a2b1 --- /dev/null +++ b/components/popout/popout-message-app.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { ProEmailView } from "@/components/pro/pro-email-tab-body"; +import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { usePolicyStore } from "@/stores/policy-store"; +import { toast } from "@/stores/toast-store"; +import type { Email } from "@/lib/jmap/types"; +import type { ProComposeTabData } from "@/stores/pro-tab-store"; +import { localizeMailboxName } from "@/lib/mailbox-label"; +import { announceMailChanged, openPopoutChannel } from "@/lib/popout/channel"; +import { openComposerWindow } from "@/lib/popout/open"; +import { closePopoutWindow } from "@/lib/popout/window-role"; +import { PopoutNotice, PopoutShell, PopoutSkeleton } from "./popout-shell"; + +/** How often an open message checks that it is still there, without a main window to say so. */ +const CHECK_INTERVAL_MS = 60_000; + +/** + * The id from the address bar: a page the service worker kept for a start + * without the network was rendered for another id. + */ +function readLaunch(paramId: string): { emailId: string; accountId?: string; ownerAccountId?: string } { + if (typeof window === "undefined") return { emailId: paramId }; + const segments = window.location.pathname.split("/").filter(Boolean); + const at = segments.lastIndexOf("message"); + let emailId = paramId; + try { + if (at >= 0 && segments[at + 1]) emailId = decodeURIComponent(segments[at + 1]); + } catch { + // A malformed address: the route's own value. + } + const search = new URLSearchParams(window.location.search); + return { emailId, accountId: search.get("account") ?? undefined, ownerAccountId: search.get("owner") ?? undefined }; +} + +type Presence = "present" | "gone"; +type LiveState = Pick<Email, "keywords" | "mailboxIds">; + +/** + * Whether the message still exists, and in which folders. A JMAP move keeps + * the id, so moved mail stays readable here; only its folders change. + */ +function useMessagePresence(emailId: string, ownerAccountId: string | undefined): { presence: Presence; mailboxIds: string[] | null; liveState: LiveState | null } { + const client = useAuthStore((s) => s.client); + const [presence, setPresence] = useState<Presence>("present"); + const [mailboxIds, setMailboxIds] = useState<string[] | null>(null); + const [liveState, setLiveState] = useState<LiveState | null>(null); + const checking = useRef(false); + + useEffect(() => { + if (!client) return; + let cancelled = false; + const check = async () => { + if (checking.current) return; + checking.current = true; + try { + const [found] = await client.getSomeEmails([emailId], ownerAccountId); + if (cancelled) return; + setPresence(found ? "present" : "gone"); + if (found?.mailboxIds) setMailboxIds(Object.keys(found.mailboxIds).sort()); + if (found) { + setLiveState({ keywords: found.keywords, mailboxIds: found.mailboxIds }); + // What the next star or read toggle starts from. + useEmailStore.setState((state) => ({ + emails: state.emails.map((email) => (email.id === emailId ? { ...email, keywords: found.keywords, mailboxIds: found.mailboxIds } : email)), + })); + } + } catch { + // No answer says nothing about the message: it stays on screen. + } finally { + checking.current = false; + } + }; + void check(); + const channel = openPopoutChannel((message) => { + if (message.type === "state-change") void check(); + }); + const onFocus = () => void check(); + window.addEventListener("focus", onFocus); + const timer = window.setInterval(() => { + if (document.visibilityState === "visible") void check(); + }, CHECK_INTERVAL_MS); + return () => { + cancelled = true; + channel?.close(); + window.removeEventListener("focus", onFocus); + window.clearInterval(timer); + }; + }, [client, emailId, ownerAccountId]); + + return { presence, mailboxIds, liveState }; +} + +function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string; accountId?: string; ownerAccountId?: string }) { + const t = useTranslations("pwaWindows"); + const tSidebar = useTranslations("sidebar"); + const login = useAuthStore((s) => accountId ?? s.activeAccountId ?? undefined); + const client = useAuthStore((s) => (login ? s.getClientForAccount(login) : undefined) ?? s.client); + const mailboxes = useEmailStore((s) => s.mailboxes); + const [subject, setSubject] = useState<string | null>(null); + const subjectInTitle = usePolicyStore((s) => s.loaded && s.isFeatureEnabled("tabTitleSubjectEnabled")); + useDocumentTitle(subjectInTitle ? subject : null); + + // Folders first: without them Delete has no Trash to move to, Archive has no + // button yet, and the toolbar moves under the pointer once they arrive. + const [foldersReady, setFoldersReady] = useState(mailboxes.length > 0); + useEffect(() => { + if (!client) return; + let cancelled = false; + void Promise.resolve(useEmailStore.getState().fetchMailboxes(client)).catch(() => {}).finally(() => { + if (!cancelled) setFoldersReady(true); + }); + return () => { + cancelled = true; + }; + // Once per client: an account without folders shouldn't ask forever. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client]); + + const { presence, mailboxIds, liveState } = useMessagePresence(emailId, ownerAccountId); + const firstMailboxIds = useRef<string | null>(null); + if (mailboxIds && firstMailboxIds.current === null) firstMailboxIds.current = mailboxIds.join(); + const moved = !!mailboxIds && firstMailboxIds.current !== mailboxIds.join(); + const movedTo = moved ? mailboxes.find((mailbox) => mailboxIds.includes(mailbox.originalId || mailbox.id)) : undefined; + + const handleClose = useCallback(() => { + announceMailChanged(login ?? null); + closePopoutWindow(); + }, [login]); + + const handleMailChanged = useCallback(() => announceMailChanged(login ?? null), [login]); + + const handleLoaded = useCallback((email: Email) => { + setSubject(email.subject || null); + // Delete, move, star and read look the message up in the list and do + // nothing when it isn't there, and this window has no list of its own. + useEmailStore.setState({ emails: [email], selectedMailbox: Object.keys(email.mailboxIds ?? {})[0] ?? "" }); + }, []); + + // Reply and forward open a composer window of their own. + const openCompose = useCallback(async (data: Omit<ProComposeTabData, "sessionId">) => { + const opened = openComposerWindow(login); + if (!opened) { + toast.error(t("popup_blocked")); + return false; + } + const shown = await opened.handOver({ + mode: data.mode, + replyTo: data.replyTo, + initialDraftText: data.initialDraftText, + initialData: data.initialData, + prefill: data.prefill, + sourceEmailId: data.sourceEmailId, + sourceEmailSource: data.sourceEmailSource, + }); + if (!shown) { + opened.cancel(); + toast.error(t("window_not_ready")); + } + return shown; + }, [login, t]); + + if (!foldersReady) return <PopoutSkeleton label={t("opening")} />; + + if (presence === "gone") { + return ( + <PopoutNotice title={t("message_gone_title")} body={t("message_gone_body")}> + <Button onClick={handleClose}>{t("close_window")}</Button> + </PopoutNotice> + ); + } + + return ( + <> + {moved && ( + <div role="status" data-popout-moved="" className="shrink-0 border-b border-border bg-muted px-4 py-2 text-sm text-foreground"> + {movedTo + ? t("message_moved_to", { folder: localizeMailboxName(movedTo.role, movedTo.name, (key) => tSidebar(`mailboxes.${key}`)) }) + : t("message_moved")} + </div> + )} + <ProEmailView + emailId={emailId} + client={client ?? undefined} + clientAccountId={login} + accountId={ownerAccountId} + onLoaded={handleLoaded} + onClose={handleClose} + onOpenCompose={openCompose} + liveState={liveState} + onMailChanged={handleMailChanged} + className="min-h-0 w-full flex-1" + /> + </> + ); +} + +/** `/popout/message/<id>`: one message in its own window, with no rail, sidebar or list. */ +export function PopoutMessageApp({ id }: { id: string }) { + const [launch] = useState(() => readLaunch(id)); + return ( + <PopoutShell kind="message" accountId={launch.accountId}> + <PopoutMessage emailId={launch.emailId} accountId={launch.accountId} ownerAccountId={launch.ownerAccountId} /> + </PopoutShell> + ); +} diff --git a/components/popout/popout-shell.tsx b/components/popout/popout-shell.tsx new file mode 100644 index 000000000..c07ec4fe7 --- /dev/null +++ b/components/popout/popout-shell.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { useConfig } from "@/hooks/use-config"; +import { useAuthStore, SHARED_SESSION_ENDED_EVENT } from "@/stores/auth-store"; +import { withBasePath } from "@/lib/browser-navigation"; +import { flushComposers } from "@/lib/compose-flush-registry"; +import { openPopoutChannel, requestWindowSession } from "@/lib/popout/channel"; +import { closePopoutWindow } from "@/lib/popout/window-role"; +import { onSignedOutElsewhere } from "@/lib/sign-out-cleanup"; + +/** How long a closing pop-out waits for its composer to save, when the account went away elsewhere. */ +const CLOSE_FLUSH_MS = 3000; +/** How long it waits to hear that another window still holds the login. */ +const STILL_HELD_MS = 800; + +/** The session gate and page frame of a pop-out window. Takes the session from the main window (lib/popout). */ +export function PopoutShell({ kind, accountId, children }: { + kind: "message" | "compose"; + /** The login to open; left out, the main window's active one. */ + accountId?: string; + children: React.ReactNode; +}) { + const t = useTranslations("pwaWindows"); + // Fetches the organisation's policy, which device policies and send rules read. + useConfig(); + const [status, setStatus] = useState<"loading" | "ready" | "no-session">("loading"); + + useEffect(() => { + let cancelled = false; + void useAuthStore.getState().startSharedSession({ accountId }).then((result) => { + if (!cancelled) setStatus(result); + }); + return () => { + cancelled = true; + }; + }, [accountId]); + + // The session ended elsewhere: save what can be saved, and go. A composer + // whose text didn't make it stays, as the only place that text still is. + const endSession = useCallback(async () => { + const flushed = await flushComposers(CLOSE_FLUSH_MS); + if (kind === "compose" && (flushed.failed > 0 || flushed.timedOut)) setStatus("no-session"); + else closePopoutWindow(); + }, [kind]); + + useEffect(() => { + const channel = openPopoutChannel((message) => { + const active = useAuthStore.getState().activeAccountId; + if (message.type !== "account-removed" || message.accountId !== active) return; + // One tab dropping a login it couldn't restore says nothing about the + // tab that is signed in with it. + void requestWindowSession({ accountId: active }, STILL_HELD_MS).then((held) => { + if (!held?.sessions.some((session) => session.accountId === active)) void endSession(); + }); + }); + const stopSignOut = onSignedOutElsewhere(() => void endSession()); + return () => { + channel?.close(); + stopSignOut(); + }; + }, [endSession]); + + // The session can't be renewed and no main window is left to do it. A + // message stops showing; a composer stays, its text still worth copying. + useEffect(() => { + const onEnded = () => setStatus("no-session"); + window.addEventListener(SHARED_SESSION_ENDED_EVENT, onEnded); + return () => window.removeEventListener(SHARED_SESSION_ENDED_EVENT, onEnded); + }, []); + + const actions = ( + <> + <Button onClick={() => window.location.replace(withBasePath("/"))}>{t("open_app")}</Button> + <Button variant="ghost" onClick={closePopoutWindow}>{t("close_window")}</Button> + </> + ); + + return ( + <div data-popout-window={kind} data-popout-status={status} className="flex h-dvh flex-col overflow-hidden bg-background pt-[var(--app-top-inset)]"> + {status === "loading" && <PopoutSkeleton label={t("opening")} />} + {status === "no-session" && kind === "message" && ( + <PopoutNotice title={t("session_ended_title")} body={t("session_ended_body")}>{actions}</PopoutNotice> + )} + {status === "no-session" && kind === "compose" && ( + <div role="alert" data-popout-session-ended="" className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border bg-muted px-4 py-2 text-sm text-foreground"> + <span className="min-w-0 flex-1">{t("session_ended_title")}. {t("session_ended_body")}</span> + {actions} + </div> + )} + {/* One place in the tree, so a composer stays mounted, text and all, when the session ends. */} + {(status === "ready" || (status === "no-session" && kind === "compose")) && children} + </div> + ); +} + +export function PopoutSkeleton({ label }: { label: string }) { + return ( + <div role="status" aria-label={label} className="flex flex-1 flex-col gap-4 p-6" data-popout-skeleton=""> + <div className="h-6 w-2/3 rounded bg-muted" /> + <div className="h-4 w-1/3 rounded bg-muted" /> + <div className="mt-4 h-4 w-full rounded bg-muted" /> + <div className="h-4 w-5/6 rounded bg-muted" /> + <div className="h-4 w-4/6 rounded bg-muted" /> + </div> + ); +} + +export function PopoutNotice({ title, body, children }: { title: string; body: string; children?: React.ReactNode }) { + return ( + <div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center" data-popout-notice=""> + <h1 className="text-lg font-semibold text-foreground">{title}</h1> + <p className="max-w-md text-sm text-muted-foreground">{body}</p> + {children && <div className="mt-2 flex items-center gap-2">{children}</div>} + </div> + ); +} diff --git a/components/popout/window-sync-host.tsx b/components/popout/window-sync-host.tsx new file mode 100644 index 000000000..f1aca1300 --- /dev/null +++ b/components/popout/window-sync-host.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { useEffect } from "react"; +import { startWindowSync } from "@/lib/desktop/window-sync"; + +/** See lib/desktop/window-sync.ts. */ +export function WindowSyncHost() { + useEffect(() => startWindowSync(), []); + return null; +} diff --git a/components/pro/__tests__/pro-email-edit-draft.test.tsx b/components/pro/__tests__/pro-email-edit-draft.test.tsx index 5a77f16e5..fec9e7483 100644 --- a/components/pro/__tests__/pro-email-edit-draft.test.tsx +++ b/components/pro/__tests__/pro-email-edit-draft.test.tsx @@ -136,6 +136,36 @@ describe('Pro email view - editing a draft', () => { expect(initialData).toMatchObject({ draftId: 'eaaaaab', draftAccountId: 'c' }); }); + describe('in a window of its own', () => { + async function editIn({ onOpenCompose, onClose }: { onOpenCompose: () => Promise<boolean>; onClose: () => void }) { + const client = { getEmail: vi.fn().mockResolvedValue(DRAFT), getUsername: () => 'userb@example.org', getAccountId: () => 'e' }; + render(<ProEmailView emailId="d1" client={client as never} onOpenCompose={onOpenCompose} onClose={onClose} />); + await waitFor(() => expect(viewerProps.current?.email).toBeTruthy()); + (viewerProps.current!.onEditDraft as () => void)(); + } + + it('leaves the message window only once the draft is on screen in the other, or it would take the draft with it', async () => { + let show: (shown: boolean) => void = () => {}; + const onClose = vi.fn(); + const onOpenCompose = vi.fn(() => new Promise<boolean>((resolve) => { show = resolve; })); + await editIn({ onOpenCompose, onClose }); + await waitFor(() => expect(onOpenCompose).toHaveBeenCalledTimes(1)); + expect(onClose).not.toHaveBeenCalled(); + show(true); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); + + it('stays when the draft did not make it', async () => { + const onClose = vi.fn(); + const onOpenCompose = vi.fn(async () => false); + await editIn({ onOpenCompose, onClose }); + await waitFor(() => expect(onOpenCompose).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(onClose).not.toHaveBeenCalled(); + expect(openComposeTab).not.toHaveBeenCalled(); + }); + }); + it('opens an own draft without an account, as before', async () => { const initialData = await openAndEdit(undefined); diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index 9a3b51ae1..0f055e027 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -15,6 +15,8 @@ import { threadKeyFor } from "@/lib/thread-utils"; import { formatRejectedRecipients } from "@/lib/jmap/client"; import { announceSent } from "@/lib/plugin-after-send"; import { composerSendOptions } from "@/lib/outgoing/composer-send-options"; +import { usePopoutsAvailable } from "@/hooks/use-popouts-available"; +import { openComposerWindow } from "@/lib/popout/open"; interface ProComposeTabBodyProps { tabId: string; @@ -212,6 +214,8 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const popoutsOn = usePopoutsAvailable(); + // Mirrors the standard shell (see the same prop in `components/mail/mail-app.tsx`). // Without it `mode === 'compose'` resolves `findComposeIdentityId(identities, // undefined)` -> null and every new message in the Pro shell defaults to the @@ -234,6 +238,22 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { ); }); + const popOut = useCallback(() => { + const target = openComposerWindow(useAuthStore.getState().activeAccountId); + if (!target) return null; + return { + cancel: target.cancel, + handOver: (state: ComposerDraftData) => target.handOver({ + mode: state.mode, + replyTo: state.replyTo, + initialData: state, + sourceEmailId: data.sourceEmailId, + sourceEmailSource: data.sourceEmailSource, + composeFromAccountEmail, + }), + }; + }, [composeFromAccountEmail, data.sourceEmailId, data.sourceEmailSource]); + return ( <div className="flex h-full w-full flex-col bg-background"> <ErrorBoundary fallback={ComposerErrorFallback}> @@ -254,6 +274,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { requestCloseRef={requestCloseRef} onDiscardDraft={handleDiscardDraft} onSaveState={handleSaveState} + popOut={popoutsOn ? popOut : undefined} className="flex-1" /> </ErrorBoundary> diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 01b0693c9..97a8c6b67 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -32,6 +32,7 @@ import { takeTabReply, useNotificationTabReply } from '@/lib/pwa-notify/tab-repl import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope'; import { canDownload } from '@/lib/device-policy'; +import { useOpenEmailInWindow } from '@/hooks/use-popouts-available'; import { isFilePreviewable } from '@/lib/file-preview'; import dynamic from 'next/dynamic'; @@ -66,6 +67,16 @@ interface ProEmailViewProps { accountId?: string; /** Fires once the full message is loaded (tab retitling, sidebar highlight). */ onLoaded?: (email: Email) => void; + /** + * Opens a reply, forward or draft somewhere else than a Pro tab: a pop-out + * window (components/popout) has no tabs. Resolves true once it is on screen + * there: a draft leaves this view only then. + */ + onOpenCompose?: (data: Omit<ProComposeTabData, 'sessionId'>) => Promise<boolean>; + /** Flags and folders as the server has them now, when the host tracks them (a pop-out). */ + liveState?: Pick<Email, 'keywords' | 'mailboxIds'> | null; + /** This view changed the message's flags, for a host other windows should hear from. */ + onMailChanged?: () => void; /** * The message left this view: deleted, archived, moved, or reopened in the * composer. The host decides what that means (close the tab / clear the @@ -121,7 +132,7 @@ function buildReplyContext(email: Email): ProReplyContext { * Hosted by `ProEmailTabBody` (a dedicated email tab) and by the folder tab's * reading pane. */ -export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onClose, className }: ProEmailViewProps) { +export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, onClose, className }: ProEmailViewProps) { const t = useTranslations(); const tNotifications = useTranslations('notifications'); const tRemove = useTranslations('email_list.confirm_remove'); @@ -176,6 +187,12 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, onOpened?: () => void, ) => { const open = (prefill: ComposePrefill | null) => { + if (onOpenCompose) { + void onOpenCompose({ ...data, prefill }).then((shown) => { + if (shown) onOpened?.(); + }); + return; + } composerSessionIdRef.current += 1; openComposeTabNow({ ...data, sessionId: composerSessionIdRef.current, prefill }); onOpened?.(); @@ -192,7 +209,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, })).then((prefill) => { if (prefill) open(Object.keys(prefill).length > 0 ? prefill : null); }); - }, [identities, openComposeTabNow, t]); + }, [identities, onOpenCompose, openComposeTabNow, t]); // Held in a ref so a host passing an inline callback doesn't refetch the // message on every render. @@ -243,6 +260,13 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, }; }, [client, clientOverride, clientAccountId, emailId, accountId, fetchEmailContent]); + useEffect(() => { + if (!liveState) return; + setEmail((prev) => (prev && prev.id === emailId + ? { ...prev, keywords: liveState.keywords ?? prev.keywords, mailboxIds: liveState.mailboxIds ?? prev.mailboxIds } + : prev)); + }, [liveState, emailId]); + const currentMailboxRole = useMemo(() => { if (!email) return undefined; const mb = email.mailboxIds @@ -368,6 +392,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, if (!client || !email) return; try { if (!(await toggleStar(client, email.id, emailKeyFor(email)))) return; + onMailChanged?.(); // Reflect locally - the viewer re-reads from email-store's selectedEmail // shape only for the mail tab; here we update our local copy too. setEmail((prev) => prev ? { @@ -381,12 +406,13 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, console.error('Toggle star failed:', err); toast.error(tNotifications('error_updating')); } - }, [client, email, toggleStar, tNotifications]); + }, [client, email, toggleStar, tNotifications, onMailChanged]); const handleMarkAsRead = useCallback(async (emailIdToMark: string, read: boolean, opts?: { automatic?: boolean }) => { if (!client) return; try { if (!(await markAsRead(client, emailIdToMark, read))) return; + onMailChanged?.(); setEmail((prev) => prev && prev.id === emailIdToMark ? { ...prev, keywords: { ...prev.keywords, $seen: read }, @@ -396,7 +422,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, // A refused mark-as-read on opening needs no notice (see EmailViewer). if (!opts?.automatic) toast.error(tNotifications('error_updating')); } - }, [client, markAsRead, tNotifications]); + }, [client, markAsRead, tNotifications, onMailChanged]); const handleSetTag = useCallback((emailIdToTag: string, tagId: string | null) => { if (!email || email.id !== emailIdToTag) return; @@ -457,6 +483,8 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, return { blob, contentType: preview.type || blob.type }; }, [preview]); + const openInWindow = useOpenEmailInWindow(); + const handleQuickReply = useCallback(async (body: string) => { handleReply(body); }, [handleReply]); @@ -534,6 +562,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, onMarkAsRead={handleMarkAsRead} onSetTag={handleSetTag} onDownloadAttachment={handleDownloadAttachment} + onOpenInWindow={openInWindow && email ? () => openInWindow(email) : undefined} onQuickReply={handleQuickReply} onSaveQuickReplyDraft={handleSaveQuickReplyDraft} onEditDraft={handleEditDraft} diff --git a/components/pro/pro-interface-redirect.tsx b/components/pro/pro-interface-redirect.tsx index fe7ea4fc2..fee73577d 100644 --- a/components/pro/pro-interface-redirect.tsx +++ b/components/pro/pro-interface-redirect.tsx @@ -76,7 +76,7 @@ export function ProInterfaceRedirect() { if (!takingOver) return null; return ( - <div className="fixed inset-0 z-50" data-pro-takeover=""> + <div className="fixed inset-0 z-50 pt-[var(--titlebar-inset)]" data-pro-takeover=""> <ProSkeleton label={label} /> </div> ); diff --git a/components/protocol/mailto-protocol-client.tsx b/components/protocol/mailto-protocol-client.tsx index 70bd5ed54..58561836e 100644 --- a/components/protocol/mailto-protocol-client.tsx +++ b/components/protocol/mailto-protocol-client.tsx @@ -1,5 +1,6 @@ "use client"; +import { isStandaloneDisplayMode } from "@/lib/standalone-display"; import { useEffect } from "react"; import { MailSkeleton } from "@/components/boot-skeleton/mail-skeleton"; import type { RailLabels } from "@/components/boot-skeleton/shell"; @@ -7,7 +8,6 @@ import { parseMailto } from "@/lib/protocol-handlers/mailto"; import { requestOpenMailtoInExistingClient, savePendingMailto } from "@/lib/protocol-handlers/session"; import { useSettingsStore } from "@/stores/settings-store"; -type StandaloneNavigator = Navigator & { standalone?: boolean }; function getProtocolPathPrefix(): string { const marker = "/protocol/mailto"; @@ -34,9 +34,7 @@ function openFallbackAppTab(raw: string): boolean { } function shouldOpenFallbackAppTab(): boolean { - const standalone = window.matchMedia?.("(display-mode: standalone)").matches - || (navigator as StandaloneNavigator).standalone === true; - return !standalone && window.history.length > 1; + return !isStandaloneDisplayMode() && window.history.length > 1; } async function focusExistingClient() { diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx index f052600d5..d3d8f4942 100644 --- a/components/protocol/protocol-launch-handler-provider.tsx +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -8,6 +8,8 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfig } from "@/hooks/use-config"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { flushComposers } from "@/lib/compose-flush-registry"; +import { isPopoutPath } from "@/lib/popout/window-role"; +import { isStandaloneDisplayMode } from "@/lib/standalone-display"; import { getPathPrefix } from "@/lib/browser-navigation"; import { matchSurface, type AppSurface } from "@/lib/deep-links"; import { @@ -45,7 +47,6 @@ type LaunchParams = { targetURL?: string; files?: ReadonlyArray<{ kind?: string; getFile?: () => Promise<File> }>; }; -type StandaloneNavigator = Navigator & { standalone?: boolean }; declare global { interface Window { @@ -91,11 +92,6 @@ const OFFER_FILE_COUNT = 5; type ShareOffer = { subject: string; body: string; files: string[] }; -function isStandaloneDisplayMode() { - return window.matchMedia?.("(display-mode: standalone)").matches - || (navigator as StandaloneNavigator).standalone === true; -} - function openProtocolInNewTab(protocol: "mailto" | "webcal", raw: string): boolean { const url = `${getPathPrefix()}/protocol/${protocol}?url=${encodeURIComponent(raw)}&fallback=1`; const opened = window.open(url, "_blank"); @@ -206,7 +202,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle }, [pathname, leaveFor]); useEffect(() => { - if (pathname.startsWith("/protocol/")) return; + if (pathname.startsWith("/protocol/") || isPopoutPath(pathname)) return; return listenForMailtoRequests((pending) => { savePendingMailto(pending); @@ -221,7 +217,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle }, [pathname, router, t, appName]); useEffect(() => { - if (typeof window === "undefined" || !window.launchQueue) return; + if (typeof window === "undefined" || !window.launchQueue || isPopoutPath(pathname)) return; window.launchQueue.setConsumer((launchParams) => { if (launchParams.files && launchParams.files.length > 0) { diff --git a/components/protocol/webcal-protocol-client.tsx b/components/protocol/webcal-protocol-client.tsx index 5a571d61a..92ff0d722 100644 --- a/components/protocol/webcal-protocol-client.tsx +++ b/components/protocol/webcal-protocol-client.tsx @@ -1,5 +1,6 @@ "use client"; +import { isStandaloneDisplayMode } from "@/lib/standalone-display"; import { useEffect } from "react"; import { parseWebcal } from "@/lib/protocol-handlers/webcal"; import { savePendingWebcal } from "@/lib/protocol-handlers/session"; @@ -7,7 +8,6 @@ import { useSettingsStore } from "@/stores/settings-store"; import { CalendarSkeleton, type CalendarSkeletonText } from "@/components/boot-skeleton/calendar-skeleton"; import type { RailLabels } from "@/components/boot-skeleton/shell"; -type StandaloneNavigator = Navigator & { standalone?: boolean }; function getProtocolPathPrefix(): string { const marker = "/protocol/webcal"; @@ -34,9 +34,7 @@ function openFallbackAppTab(raw: string): boolean { } function shouldOpenFallbackAppTab(): boolean { - const standalone = window.matchMedia?.("(display-mode: standalone)").matches - || (navigator as StandaloneNavigator).standalone === true; - return !standalone && window.history.length > 1; + return !isStandaloneDisplayMode() && window.history.length > 1; } interface WebcalProtocolClientProps { diff --git a/components/settings/account-security-settings.tsx b/components/settings/account-security-settings.tsx index acee9fd75..184c979e3 100644 --- a/components/settings/account-security-settings.tsx +++ b/components/settings/account-security-settings.tsx @@ -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" /> diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index 93a658443..cea0b8fec 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -910,7 +910,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = if (!isDesktop) { if (mobileShowContent) { return ( - <div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}> + <div className={cn("flex flex-col bg-background pt-[var(--app-top-inset)]", isEmbedded ? "h-full" : "h-dvh")}> <AppTopBannerSlot /> <div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0"> <IconButton label={t('back_to_settings')} size="icon-lg" onClick={() => window.history.back()}> @@ -939,7 +939,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = } return ( - <div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}> + <div className={cn("flex flex-col bg-background pt-[var(--app-top-inset)]", isEmbedded ? "h-full" : "h-dvh")}> <AppTopBannerSlot /> <div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0"> <IconButton label={t('back_to_mail')} size="icon-lg" onClick={() => router.push('/')}> @@ -1057,7 +1057,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = </AppMain> ); return ( - <div className={cn("flex flex-col pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}> + <div className={cn("flex flex-col pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}> {!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>} <AppTopBannerSlot /> {mvShell && ( diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 4512d1c74..88fba7e0c 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -155,8 +155,8 @@ export function Dialog({ return createPortal( <div className={cn( - "fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150", - size === "full-on-phone" && "max-sm:p-0" + "fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 pt-[calc(1rem+var(--titlebar-inset))] animate-in fade-in duration-150", + size === "full-on-phone" && "max-sm:p-0 max-sm:pt-[var(--titlebar-inset)]" )} onMouseDown={(e) => { pressedOnBackdrop.current = e.target === e.currentTarget; diff --git a/hooks/use-calendar-alerts.ts b/hooks/use-calendar-alerts.ts index 7915de752..8c65c12ee 100644 --- a/hooks/use-calendar-alerts.ts +++ b/hooks/use-calendar-alerts.ts @@ -3,6 +3,7 @@ import { useEffect, useRef, useCallback } from 'react'; import { useTranslations, useLocale } from 'next-intl'; import { parseISO } from 'date-fns'; +import { isPopoutWindow } from '@/lib/popout/window-role'; import { useAuthStore } from '@/stores/auth-store'; import { useCalendarStore } from '@/stores/calendar-store'; import { useSettingsStore } from '@/stores/settings-store'; @@ -72,7 +73,9 @@ function remind(view: CalendarAlertView, show: () => void): void { } export function useCalendarAlerts() { - const { isAuthenticated, client } = useAuthStore(); + const { isAuthenticated: signedIn, client } = useAuthStore(); + // The main window rings; a pop-out would ring the same alert again. + const isAuthenticated = signedIn && !isPopoutWindow(); const { events, calendars, supportsCalendar } = useCalendarStore(); const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks, notificationSoundChoice } = useSettingsStore(); const { tasks: storeTasks } = useTaskStore(); diff --git a/hooks/use-popouts-available.ts b/hooks/use-popouts-available.ts new file mode 100644 index 000000000..7628846a0 --- /dev/null +++ b/hooks/use-popouts-available.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { useAuthStore } from "@/stores/auth-store"; +import { resolveEmailBlobContext } from "@/stores/email-store"; +import { toast } from "@/stores/toast-store"; +import type { Email } from "@/lib/jmap/types"; +import { openMessageWindow } from "@/lib/popout/open"; +import { popoutsAvailable } from "@/lib/popout/support"; +import { isPopoutWindow } from "@/lib/popout/window-role"; + +/** + * Whether to offer "Open in new window" here. False on the server and in the + * first render, so the markup hydrates the same; false in the demo, whose + * client has no session to share, and inside a pop-out (for a message: it is + * already in its own window). + */ +export function usePopoutsAvailable(): boolean { + const isDemoMode = useAuthStore((s) => s.isDemoMode); + const [available, setAvailable] = useState(false); + useEffect(() => { + const check = () => setAvailable(popoutsAvailable() && !isPopoutWindow()); + check(); + // Installing the running tab turns it into a window without a reload. + if (typeof window.matchMedia !== "function") return; + const media = window.matchMedia("(display-mode: standalone)"); + media.addEventListener?.("change", check); + return () => media.removeEventListener?.("change", check); + }, []); + return available && !isDemoMode; +} + +/** + * Opens a message in its own window, through the login and the account it was + * read in. Null when that isn't on offer here. Says so when the browser + * blocks the window; nothing else changes then. + */ +export function useOpenEmailInWindow(): ((email: Email) => void) | null { + const available = usePopoutsAvailable(); + const t = useTranslations("pwaWindows"); + const open = useCallback((email: Email) => { + const auth = useAuthStore.getState(); + const source = resolveEmailBlobContext(email, auth.client); + const opened = openMessageWindow({ + emailId: email.id, + localAccountId: source.clientAccountId ?? auth.activeAccountId, + ownerAccountId: source.accountId, + }); + if (!opened) toast.error(t("popup_blocked")); + }, [t]); + return available ? open : null; +} diff --git a/integration/tests/22-ent-offline.spec.ts b/integration/tests/22-ent-offline.spec.ts index e9f684b81..8f3a65e29 100644 --- a/integration/tests/22-ent-offline.spec.ts +++ b/integration/tests/22-ent-offline.spec.ts @@ -160,6 +160,46 @@ async function offlineDatabaseExists(page: Page): Promise<boolean> { }); } +/** + * The store's files as bytes, with no worker holding them: which of `needles` + * occur in them. Goes to a same-origin file without scripts first, so that + * leaving the app ends the owner. + */ +async function rawStoreFiles(page: Page, needles: string[]): Promise<{ bytes: number; found: string[] }> { + await page.goto(new URL('/sw.js', page.url()).href); + return page.evaluate(async (wanted) => { + const read = async (dir: FileSystemDirectoryHandle): Promise<string[]> => { + const parts: string[] = []; + for await (const handle of (dir as unknown as { values(): AsyncIterable<FileSystemHandle> }).values()) { + if (handle.kind === 'directory') parts.push(...await read(handle as FileSystemDirectoryHandle)); + else parts.push(new TextDecoder('latin1').decode(await (await (handle as FileSystemFileHandle).getFile()).arrayBuffer())); + } + return parts; + }; + const root = await navigator.storage.getDirectory(); + const files = await read(await root.getDirectoryHandle('.bulwark-store')); + return { + bytes: files.reduce((sum, file) => sum + file.length, 0), + found: wanted.filter((needle) => files.some((file) => file.includes(needle))), + }; + }, needles); +} + +/** The file of the account's store, from the key database. */ +async function storeFileName(page: Page): Promise<string> { + return page.evaluate(() => new Promise<string>((resolve, reject) => { + const open = indexedDB.open('bulwark-store'); + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const all = open.result.transaction('accounts').objectStore('accounts').getAll(); + all.onsuccess = () => { + open.result.close(); + resolve((all.result as Array<{ file: string }>)[0].file); + }; + }; + })); +} + /** The accounts the local store holds a key for. Their mail is in encrypted files nothing here can read. */ async function offlineDatabase(page: Page): Promise<{ exists: boolean; accounts: number }> { return page.evaluate(async () => { @@ -238,6 +278,45 @@ test('with the browser offline, kept mail opens, also after a reload without a n await page.context().close(); }); +test('the files on the device hold no readable mail, and a store whose key went starts over', async ({ browser }) => { + const subject = `Offline encrypted ${RUN}`; + const secret = `Secret body ${RUN}`; + await deliverAndWait(subject, secret); + const page = await userPage(browser, ENT.alice); + await enableOffline(page); + await toMail(page); + await expect.poll(async () => (await offlineDatabase(page)).accounts, { timeout: 30_000 }).toBeGreaterThan(0); + await openMessage(page, subject); + await expect(body(page)).toContainText(secret, { timeout: 20_000 }); + // Long enough for the store to have the message and its text on disk. + await page.waitForTimeout(5_000); + const file = await storeFileName(page); + + const raw = await rawStoreFiles(page, [subject, secret, 'SQLite format 3', file]); + expect(raw.bytes).toBeGreaterThan(100_000); + // The name of the file is the one thing here in the clear: the search works. + expect(raw.found).toEqual([file]); + + // The browser clears the key database and leaves the files. + await page.evaluate(() => new Promise<void>((resolve) => { + const request = indexedDB.deleteDatabase('bulwark-store'); + request.onsuccess = () => resolve(); + request.onerror = () => resolve(); + request.onblocked = () => resolve(); + })); + await page.goto(new URL('/', page.url()).href); + // Once on screen and once for screen readers. + await expect(page.locator('p', { hasText: "couldn't be read" }).first()).toBeVisible({ timeout: 60_000 }); + await toMail(page); + await expect(page.locator('[data-testid="email-list-item"]', { hasText: subject }).first()).toBeVisible({ timeout: 60_000 }); + await expect.poll(async () => (await offlineDatabase(page)).accounts, { timeout: 30_000 }).toBeGreaterThan(0); + const newFile = await storeFileName(page); + expect(newFile).not.toBe(file); + const after = await rawStoreFiles(page, [file, newFile]); + expect(after.found).toEqual([newFile]); + await page.context().close(); +}); + test('a mail written offline waits in the outbox, the required label still stops it, and it goes out labelled when back online', async ({ browser }) => { const subject = `Outbox label ${RUN}`; await setPolicy(browser, { organisation: { labels: { list: LABELS, defaultId: '', required: true, extraHeader: '' } } }); @@ -396,8 +475,8 @@ test('emptying the Trash offline shows at once and reaches the server once back await page.context().close(); }); -test('a send whose response got lost goes out once', async ({ browser }) => { - const subject = `Offline lost response ${RUN}`; +/** A send taken from the outbox gets no answer. `arrives` is what reached the server of it. */ +async function loseResponse(browser: Browser, subject: string, arrives: (body: { methodCalls: Array<[string, unknown, string]> }) => void) { const page = await userPage(browser, ENT.alice); await enableOffline(page); await toMail(page); @@ -413,9 +492,12 @@ test('a send whose response got lost goes out once', async ({ browser }) => { let lost = 0; await context.unroute(origin); await context.route(origin, async (route) => { - if (lost === 0 && (route.request().postData() ?? '').includes('EmailSubmission/set')) { + const sent = route.request().postData() ?? ''; + if (lost === 0 && sent.includes('EmailSubmission/set')) { lost += 1; - await route.fetch(); + const body = JSON.parse(sent); + arrives(body); + await route.fetch({ postData: JSON.stringify(body) }); await route.abort('connectionreset'); return; } @@ -430,6 +512,16 @@ test('a send whose response got lost goes out once', async ({ browser }) => { expect(copies).toHaveLength(1); expect(copies[0].keywords.$draft).toBeFalsy(); await context.close(); +} + +test('a send whose response got lost goes out once', async ({ browser }) => { + await loseResponse(browser, `Offline lost response ${RUN}`, () => {}); +}); + +test('a send of which only the draft arrived is submitted, and goes out once', async ({ browser }) => { + await loseResponse(browser, `Offline lost submission ${RUN}`, (body) => { + body.methodCalls = body.methodCalls.filter(([name]) => name === 'Email/set'); + }); }); test('signing out asks first when unsent mail would be lost, then wipes the device', async ({ browser }) => { @@ -733,6 +825,7 @@ test('a store the browser deleted is noticed, says what was lost and syncs again await page.goto('/settings/backup/offline'); await expect(page.getByTestId('offline-evicted')).toContainText('The browser deleted the mail kept on this device', { timeout: 60_000 }); + await expect(page.getByText("Offline mail on this device couldn't be read", { exact: false }).first()).toBeVisible(); await expect(page.getByTestId('offline-evicted')).toContainText('2 unsent messages or changes were lost'); await expect.poll(() => keptMessages(page), { timeout: 180_000, intervals: [2000] }).toBeGreaterThan(0); await page.getByTestId('offline-evicted').getByRole('button', { name: 'OK' }).click(); diff --git a/integration/tests/23-do-pwa-windows.spec.ts b/integration/tests/23-do-pwa-windows.spec.ts new file mode 100644 index 000000000..a573d1707 --- /dev/null +++ b/integration/tests/23-do-pwa-windows.spec.ts @@ -0,0 +1,675 @@ +import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test'; +import { ENT, adminContext, contextOptions, deliver, jmapAs, requireEntEnv, shot, withPolicy, ENT_PASSWORD } from './helpers/ent'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; + +/** + * Windows of an installed app (design B, step 2) through the real UI on a + * VM 106 test Stalwart with the ent-* accounts: + * - the title bar next to the native window controls; + * - "Save all to a folder", with a stubbed showDirectoryPicker; + * - pop-out message and composer windows: session, draft handover, send, + * close with unsaved text, a message deleted or moved, state across windows. + * + * Headless Chromium has no installed-app mode, so the display mode and the + * overlay API are stubbed by an init script (which reaches popups too). + * window.open, the second window and everything behind it are real. What this + * can't show: a real install opening a popup inside the app rather than in + * the browser. A person has to check that once in Chrome and Edge. + * + * Rig: + * BUILD=1 bash start-prod.sh <worktree> <port> <jmap-url> pw-1.0 + * bash run-spec.sh <worktree> <port> <jmap-url> 1.0 integration/tests/23-do-pwa-windows.spec.ts + */ + +test.setTimeout(180_000); + +const RUN = Date.now().toString(36); +/** A server check every second: tighter polling gets a test account rate limited. */ +const POLL = [1000]; +const STANDALONE = `(() => { + const real = window.matchMedia.bind(window); + window.matchMedia = (query) => query.includes('display-mode: standalone') + ? { matches: true, media: query, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent() { return false; } } + : real(query); +})();`; +const OVERLAY = `(() => { + const overlay = new EventTarget(); + overlay.visible = true; + overlay.getTitlebarAreaRect = () => ({ x: 0, y: 0, width: window.innerWidth - 140, height: 40 }); + Object.defineProperty(navigator, 'windowControlsOverlay', { value: overlay }); +})();`; +const BRIDGE = `window.bulwarkDesktop = { windows: {} };`; + +async function appPage(browser: Browser, ...initScripts: string[]): Promise<Page> { + const context = await browser.newContext(contextOptions()); + for (const script of initScripts) await context.addInitScript(script); + const page = await context.newPage(); + await neutralizeDevOverlay(page); + await page.goto('/'); + await page.fill('#username', ENT.alice); + await page.fill('#password', ENT_PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 }); + return page; +} + +/** A toast: the text is also in the live region for screen readers. */ +function toast(page: Page, text: string | RegExp) { + return page.getByRole('region', { name: 'Notifications' }).getByText(text).first(); +} + +function row(page: Page, subject: string) { + return page.locator('[data-testid="email-list-item"]', { hasText: subject }).first(); +} + +async function openInMain(page: Page, subject: string): Promise<void> { + await expect(row(page, subject)).toBeVisible({ timeout: 60_000 }); + await row(page, subject).click(); +} + +async function deliverToAlice(subject: string): Promise<string> { + await deliver({ from: ENT.bob, to: [ENT.alice], subject, text: `Body of ${subject}` }); + const alice = await jmapAs(ENT.alice); + const found = await alice.waitForEmail(subject, { timeoutMs: 30_000 }); + return found.id; +} + +/** A message in Alice's inbox with two attachments of the same name and one other. */ +async function messageWithAttachments(subject: string): Promise<void> { + const alice = await jmapAs(ENT.alice); + const inbox = await alice.mailboxByRole('inbox'); + const res = await alice.request([['Email/set', { + accountId: alice.accountId, + create: { m: { + mailboxIds: { [inbox!.id]: true }, + from: [{ email: ENT.bob }], + to: [{ email: ENT.alice }], + subject, + bodyValues: { + t: { value: 'See the files.' }, + a: { value: 'first copy' }, + b: { value: 'second copy' }, + c: { value: 'notes' }, + }, + bodyStructure: { + type: 'multipart/mixed', + subParts: [ + { partId: 't', type: 'text/plain' }, + { partId: 'a', type: 'text/plain', name: 'same.txt', disposition: 'attachment' }, + { partId: 'b', type: 'text/plain', name: 'same.txt', disposition: 'attachment' }, + { partId: 'c', type: 'text/plain', name: 'notes.txt', disposition: 'attachment' }, + ], + }, + } }, + }, '0']]); + if (!res.methodResponses[0][1].created?.m) throw new Error(JSON.stringify(res.methodResponses[0][1])); +} + +/** Stubs the picker on the page, with a folder that records what it is given. */ +async function stubDirectoryPicker(page: Page, opts: { failOn?: string; cancel?: boolean } = {}): Promise<void> { + await page.evaluate(({ failOn, cancel }) => { + const files = new Map<string, string>(); + (window as unknown as { __folderFiles: Map<string, string> }).__folderFiles = files; + (window as unknown as { __pickerCalls: number }).__pickerCalls = 0; + (window as unknown as { showDirectoryPicker: unknown }).showDirectoryPicker = async () => { + (window as unknown as { __pickerCalls: number }).__pickerCalls += 1; + if (cancel) throw new DOMException('closed', 'AbortError'); + return { + name: 'Saved mail', + async getFileHandle(name: string, options?: { create?: boolean }) { + if (!files.has(name) && !options?.create) throw new DOMException('missing', 'NotFoundError'); + return { + createWritable: async () => ({ + write: async (data: Blob) => { + if (name === failOn) throw new DOMException('Disk full', 'QuotaExceededError'); + files.set(name, await data.text()); + }, + close: async () => {}, + abort: async () => {}, + }), + }; + }, + async removeEntry(name: string) { files.delete(name); }, + }; + }; + }, opts); +} + +const folderFiles = (page: Page) => page.evaluate(() => [...(window as unknown as { __folderFiles: Map<string, string> }).__folderFiles.entries()]); + +function watchAuthCalls(context: BrowserContext): string[] { + const calls: string[] = []; + context.on('request', (request) => { + const url = new URL(request.url()); + if (request.method() !== 'PUT') return; + if (url.pathname === '/api/auth/token' || url.pathname === '/api/auth/session') { + calls.push(`${request.frame().page().url()} ${url.pathname}${url.search}`); + } + }); + return calls; +} + +async function openPopout(page: Page, trigger: () => Promise<void>): Promise<Page> { + const [popup] = await Promise.all([page.context().waitForEvent('page'), trigger()]); + await popup.waitForLoadState('domcontentloaded'); + return popup; +} + +async function popOutMessage(page: Page, subject: string): Promise<Page> { + await openInMain(page, subject); + const button = page.locator('[data-testid="viewer-open-in-window"]').first(); + await expect(button).toBeVisible({ timeout: 30_000 }); + const popup = await openPopout(page, () => button.click()); + await expect(popup.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 }); + return popup; +} + +async function draftsCalled(subject: string): Promise<Array<{ id: string; subject: string }>> { + const alice = await jmapAs(ENT.alice); + const drafts = await alice.mailboxByRole('drafts'); + const res = await alice.request([ + ['Email/query', { accountId: alice.accountId, filter: { inMailbox: drafts!.id, subject } }, '0'], + ['Email/get', { accountId: alice.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'] }, '1'], + ]); + return res.methodResponses[1][1].list; +} + +test.beforeAll(() => requireEntEnv()); + +test.describe('the manifest and the title bar', () => { + test('the manifest asks for the overlay and falls back to standalone', async ({ request, baseURL }) => { + const res = await request.get(`${baseURL ?? process.env.IT_WEBMAIL_URL}/manifest.webmanifest`); + const manifest = await res.json(); + expect(manifest.display).toBe('standalone'); + expect(manifest.display_override).toEqual(['window-controls-overlay', 'standalone']); + }); + + test('without an overlay nothing changes', async ({ browser }) => { + const page = await appPage(browser); + await expect(page.locator('[data-window-title-bar]')).toHaveCount(0); + const padding = await page.evaluate(() => { + const root = document.querySelector('[class*="--app-top-inset"]') as HTMLElement; + return { padding: getComputedStyle(root).paddingTop, top: root.getBoundingClientRect().top }; + }); + expect(padding).toEqual({ padding: '0px', top: 0 }); + await page.context().close(); + }); + + test('with one, the app starts below a drag strip that leaves the controls free', async ({ browser }) => { + const page = await appPage(browser, OVERLAY); + const strip = page.locator('[data-window-title-bar]'); + await expect(strip).toBeVisible(); + const box = await strip.boundingBox(); + expect(box).toMatchObject({ x: 0, y: 0, height: 40 }); + expect(box!.width).toBe(1440 - 140); + const region = await strip.evaluate((el) => { + const style = getComputedStyle(el) as CSSStyleDeclaration & { appRegion?: string; webkitAppRegion?: string }; + return style.appRegion ?? style.webkitAppRegion; + }); + expect(region).toBe('drag'); + // The window title, in small text. + await expect(strip).toContainText(/\S/); + const rail = await page.evaluate(() => { + const root = document.querySelector('[class*="--app-top-inset"]') as HTMLElement; + return { padding: getComputedStyle(root).paddingTop }; + }); + expect(rail.padding).toBe('40px'); + await shot(page, 'pwa-windows-title-bar'); + // A full-page dialog keeps clear of it too. + await page.context().close(); + }); +}); + +test.describe('save all attachments to a folder', () => { + const SUBJECT = `Files ${RUN}`; + + test.beforeAll(() => messageWithAttachments(SUBJECT)); + + test('writes every file, numbers the same name, and says how many', async ({ browser }) => { + const page = await appPage(browser); + await stubDirectoryPicker(page); + await openInMain(page, SUBJECT); + await page.locator('[data-testid="attachments-save-to-folder"]').first().click(); + await expect(toast(page, 'Saved 3 attachments to Saved mail.')).toBeVisible({ timeout: 30_000 }); + const files = new Map(await folderFiles(page)); + expect([...files.keys()].sort()).toEqual(['notes.txt', 'same (1).txt', 'same.txt']); + expect(files.get('notes.txt')).toContain('notes'); + expect([files.get('same.txt'), files.get('same (1).txt')].map((text) => text!.trim()).sort()).toEqual(['first copy', 'second copy']); + await page.context().close(); + }); + + test('a write failing midway says how many were saved', async ({ browser }) => { + const page = await appPage(browser); + await stubDirectoryPicker(page, { failOn: 'notes.txt' }); + await openInMain(page, SUBJECT); + await page.locator('[data-testid="attachments-save-to-folder"]').first().click(); + await expect(toast(page, 'Saved 2 of 3 attachments to Saved mail.')).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('closing the picker does nothing', async ({ browser }) => { + const page = await appPage(browser); + await stubDirectoryPicker(page, { cancel: true }); + await openInMain(page, SUBJECT); + await page.locator('[data-testid="attachments-save-to-folder"]').first().click(); + await expect.poll(() => page.evaluate(() => (window as unknown as { __pickerCalls: number }).__pickerCalls)).toBe(1); + await expect(toast(page, /Saved \d/)).toHaveCount(0); + await page.context().close(); + }); + + test('is gone when the organisation blocks downloads', async ({ browser }) => { + const admin = await adminContext(browser); + const page = await appPage(browser); + await stubDirectoryPicker(page); + await withPolicy(admin, { organisation: { devices: { noDownloads: true } } }, async () => { + await page.reload(); + await stubDirectoryPicker(page); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 }); + await openInMain(page, SUBJECT); + await expect(page.getByTestId('attachments-downloads-off').first()).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="attachments-save-to-folder"]')).toHaveCount(0); + }); + await admin.close(); + await page.context().close(); + }); +}); + +test.describe('pop-out windows', () => { + test('paints from the server HTML: its own frame, no spinner, no rail, the theme set before the first paint', async ({ browser }) => { + const context = await browser.newContext(contextOptions()); + for (const path of ['/en/popout/compose', '/en/popout/message/x1']) { + const res = await context.request.get(path); + expect(res.status(), path).toBe(200); + const html = await res.text(); + expect(html, path).toContain('data-popout-skeleton'); + expect(html, path).toContain('data-popout-status="loading"'); + expect(html, path).not.toContain('animate-spin'); + expect(html, path).not.toContain('data-sk-rail'); + expect(html, path).not.toContain('data-boot-skeleton='); + expect(html.indexOf('theme-storage'), path).toBeLessThan(html.indexOf('data-popout-window')); + } + await context.close(); + }); + + test('a browser tab offers none of it', async ({ browser }) => { + const subject = `Tab ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser); + await openInMain(page, subject); + await expect(page.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="viewer-open-in-window"]')).toHaveCount(0); + await page.keyboard.press('c'); + await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' }); + await expect(page.locator('[data-testid="composer-open-in-window"]')).toHaveCount(0); + await page.context().close(); + }); + + test('the bridge counts too: display-mode is `browser` in Electron', async ({ browser }) => { + const subject = `Bridge ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser, BRIDGE); + await openInMain(page, subject); + await expect(page.locator('[data-testid="viewer-open-in-window"]').first()).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('a message opens alone, on the main window\'s session, with no rail and no token renewal', async ({ browser }) => { + const subject = `Popout ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser, STANDALONE); + const calls = watchAuthCalls(page.context()); + const popup = await popOutMessage(page, subject); + await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 }); + await expect(popup.locator('nav')).toHaveCount(0); + expect(popup.url()).toContain('/popout/message/'); + await expect(popup).toHaveTitle(new RegExp(subject)); + expect(calls.filter((call) => call.includes('/popout/'))).toEqual([]); + await shot(popup, 'pwa-windows-popout-message'); + await page.context().close(); + }); + + test('opening the same message again focuses its window', async ({ browser }) => { + const subject = `Twice ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser, STANDALONE); + const popup = await popOutMessage(page, subject); + const opened: Page[] = []; + page.context().on('page', (p) => opened.push(p)); + const url = popup.url(); + let reloaded = false; + popup.on('framenavigated', () => { reloaded = true; }); + await page.locator('[data-testid="viewer-open-in-window"]').first().click(); + await popup.waitForTimeout(1000); + expect(opened).toEqual([]); + expect(reloaded).toBe(false); + expect(popup.url()).toBe(url); + await page.context().close(); + }); + + test('a session with nothing to restore still works, and survives in the list', async ({ browser }) => { + const subject = `Forgotten ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser, STANDALONE); + await page.evaluate(() => { + const registry = JSON.parse(localStorage.getItem('account-registry')!); + for (const account of registry.state.accounts) account.rememberMe = false; + localStorage.setItem('account-registry', JSON.stringify(registry)); + }); + await page.context().clearCookies({ name: 'jmap_session' }); + const popup = await popOutMessage(page, subject); + await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 }); + const accounts = await page.evaluate(() => JSON.parse(localStorage.getItem('account-registry')!).state.accounts.length); + expect(accounts).toBeGreaterThan(0); + await expect(accountSwitcher(page)).toBeVisible(); + await page.context().close(); + }); + + test('a reload with the main window gone restores from the cookies, without evicting', async ({ browser }) => { + const subject = `Alone ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser, STANDALONE); + const popup = await popOutMessage(page, subject); + const before = await popup.evaluate(() => localStorage.getItem('account-registry')); + await page.close(); + await popup.reload(); + await expect(popup.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 }); + await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 }); + expect(await popup.evaluate(() => localStorage.getItem('account-registry'))).toBe(before); + await popup.context().close(); + }); + + test('a message deleted or moved while open says so', async ({ browser }) => { + const subject = `Gone ${RUN}`; + const emailId = await deliverToAlice(subject); + const page = await appPage(browser, STANDALONE); + const popup = await popOutMessage(page, subject); + await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 }); + + const alice = await jmapAs(ENT.alice); + const archive = await alice.mailboxByRole('archive'); + if (archive) { + await alice.moveEmail(emailId, archive.id); + await popup.evaluate(() => window.dispatchEvent(new Event('focus'))); + await expect(popup.locator('[data-popout-moved]')).toContainText('moved to', { timeout: 30_000 }); + // A move keeps the id: the message stays readable. + await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible(); + } + + await alice.request([['Email/set', { accountId: alice.accountId, destroy: [emailId] }, '0']]); + await popup.evaluate(() => window.dispatchEvent(new Event('focus'))); + await expect(popup.getByText('This message is gone')).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('deleting from the pop-out closes it', async ({ browser }) => { + const subject = `Delete ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser, STANDALONE); + const popup = await popOutMessage(page, subject); + await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 }); + const closed = popup.waitForEvent('close'); + await popup.getByRole('button', { name: /^Delete$/ }).first().click(); + await closed; + // The main window's list follows. + await expect(row(page, subject)).toHaveCount(0, { timeout: 30_000 }); + const alice = await jmapAs(ENT.alice); + const trash = await alice.mailboxByRole('trash'); + await expect.poll(async () => Object.keys((await alice.findEmailBySubject(subject))?.mailboxIds ?? {}), { intervals: POLL }).toEqual([trash!.id]); + }); + + test('state follows across windows without a reload', async ({ browser }) => { + const subject = `Sync ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser, STANDALONE); + const popup = await popOutMessage(page, subject); + await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 }); + let navigated = 0; + popup.on('framenavigated', () => { navigated += 1; }); + + // Theme. + await page.evaluate(() => { + const stored = JSON.parse(localStorage.getItem('theme-storage') ?? '{"state":{"theme":"light"},"version":1}'); + stored.state.theme = 'dark'; + localStorage.setItem('theme-storage', JSON.stringify(stored)); + }); + await expect(popup.locator('html')).toHaveClass(/dark/, { timeout: 10_000 }); + + // Language. + await page.evaluate(() => localStorage.setItem('locale-storage', JSON.stringify({ state: { locale: 'de' }, version: 0 }))); + await expect(popup.locator('html')).toHaveAttribute('lang', 'de', { timeout: 15_000 }); + await page.evaluate(() => localStorage.setItem('locale-storage', JSON.stringify({ state: { locale: 'en' }, version: 0 }))); + await expect(popup.locator('html')).toHaveAttribute('lang', 'en', { timeout: 15_000 }); + + // Read state: from the pop-out to the main window's list. The main window + // looks at another message: the one it has open it would mark read again. + await expect(row(page, subject)).toHaveAttribute('data-unread', 'false', { timeout: 30_000 }); + const other = `Sync other ${RUN}`; + await deliverToAlice(other); + await openInMain(page, other); + await popup.getByTitle('Mark as unread').first().click(); + await expect(row(page, subject)).toHaveAttribute('data-unread', 'true', { timeout: 30_000 }); + + // And from the main window to the pop-out. + await row(page, subject).click(); + await expect(row(page, subject)).toHaveAttribute('data-unread', 'false', { timeout: 30_000 }); + await expect(popup.getByTitle('Mark as unread').first()).toBeVisible({ timeout: 30_000 }); + expect(navigated).toBe(0); + await page.context().close(); + }); +}); + +test.describe('a composer in its own window', () => { + async function popOutComposer(page: Page, subject: string): Promise<Page> { + await page.keyboard.press('c'); + await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 }); + const input = page.locator('[data-testid="composer-to"] input').first(); + await input.click(); + await input.fill(ENT.bob); + await input.press('Enter'); + await page.locator('[data-testid="composer-subject"]').first().fill(subject); + await page.locator('[data-testid="email-composer"] [contenteditable="true"]').first().fill(`Body of ${subject}`); + const popup = await openPopout(page, () => page.locator('[data-testid="composer-open-in-window"]').first().click()); + await expect(popup.locator('[data-popout-window="compose"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 }); + await expect(popup.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject, { timeout: 30_000 }); + return popup; + } + + test('the draft moves over under one draft id, and the window closes after sending', async ({ browser }) => { + const subject = `Moved ${RUN}`; + const page = await appPage(browser, STANDALONE); + const popup = await popOutComposer(page, subject); + + // Gone from the main window, with no "continue draft" left behind. + await expect(page.locator('[data-testid="email-composer"]')).toHaveCount(0); + await expect(popup.locator('[data-testid="composer-to"]')).toContainText(ENT.bob); + await expect(popup.locator('[data-testid="email-composer"] [contenteditable="true"]').first()).toContainText(`Body of ${subject}`); + await expect(popup.locator('nav')).toHaveCount(0); + await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1); + const [first] = await draftsCalled(subject); + await shot(popup, 'pwa-windows-popout-composer'); + + // Further edits replace that draft, they don't start another. + await popup.locator('[data-testid="composer-subject"]').first().fill(`${subject} edited`); + await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 15_000, intervals: POLL }).toBe(1); + await expect.poll(async () => (await draftsCalled(subject)).map((draft) => draft.id).includes(first.id), { timeout: 15_000, intervals: POLL }).toBe(false); + + const closed = popup.waitForEvent('close'); + await popup.locator('[data-testid="composer-send"]').first().click(); + await closed; + + const bob = await jmapAs(ENT.bob); + await bob.waitForEmail(`${subject} edited`, { timeoutMs: 60_000 }); + await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 20_000, intervals: POLL }).toBe(0); + // The main window says it went, since the pop-out can't any more. + await expect(toast(page, 'Email sent')).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('closing the window with unsaved text saves the draft', async ({ browser }) => { + const subject = `Unsaved ${RUN}`; + const page = await appPage(browser, STANDALONE); + const popup = await popOutComposer(page, subject); + await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1); + const typed = `${subject} typed`; + await popup.locator('[data-testid="composer-subject"]').first().fill(typed); + // Inside the 2 s of the autosave, so only the close can save it. No prompt: the main window takes it. + let prompted = false; + popup.on('dialog', (dialog) => { prompted = true; void dialog.accept(); }); + const closed = popup.waitForEvent('close'); + await popup.close({ runBeforeUnload: true }); + await closed; + expect(prompted).toBe(false); + await expect.poll(async () => (await draftsCalled(typed)).length, { timeout: 20_000, intervals: POLL }).toBe(1); + // It replaced the version the window started from. + await expect.poll(async () => (await draftsCalled(subject)).filter((draft) => draft.subject === subject).length, { timeout: 20_000, intervals: POLL }).toBe(0); + await page.context().close(); + }); + + test('with no main window left it asks, and staying lets the save finish', async ({ browser }) => { + const subject = `Asked ${RUN}`; + const page = await appPage(browser, STANDALONE); + const popup = await popOutComposer(page, subject); + await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1); + await page.close(); + const typed = `${subject} typed`; + await popup.locator('[data-testid="composer-subject"]').first().fill(typed); + const asked = new Promise<void>((resolve) => { + popup.once('dialog', (dialog) => { + expect(dialog.type()).toBe('beforeunload'); + void dialog.dismiss(); + resolve(); + }); + }); + await popup.close({ runBeforeUnload: true }); + await asked; + expect(popup.isClosed()).toBe(false); + await expect.poll(async () => (await draftsCalled(typed)).length, { timeout: 20_000, intervals: POLL }).toBe(1); + }); + + test('a blocked window leaves the composer where it is', async ({ browser }) => { + const subject = `Blocked ${RUN}`; + const page = await appPage(browser, STANDALONE); + await page.evaluate(() => { window.open = () => null; }); + await page.keyboard.press('c'); + await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 }); + await page.locator('[data-testid="composer-subject"]').first().fill(subject); + await page.locator('[data-testid="composer-open-in-window"]').first().click(); + await expect(toast(page, /blocked the new window/)).toBeVisible(); + await expect(page.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject); + await page.context().close(); + }); +}); + +test.describe('what a window must not lose', () => { + test('text whose save fails in the main window lands in its "Continue draft" slot', async ({ browser }) => { + const subject = `Refused ${RUN}`; + const page = await appPage(browser, STANDALONE); + await page.keyboard.press('c'); + await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 }); + await page.locator('[data-testid="composer-subject"]').first().fill(subject); + const popup = await openPopout(page, () => page.locator('[data-testid="composer-open-in-window"]').first().click()); + await expect(popup.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject, { timeout: 60_000 }); + await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1); + // Give the pop-out time to see the main window answer its ping. + await popup.waitForTimeout(500); + const typed = `${subject} typed`; + await popup.locator('[data-testid="composer-subject"]').first().fill(typed); + // The server stops taking saves, for the main window's rescue too. + await page.context().route('**/jmap/**', async (route) => { + if ((route.request().postData() ?? '').includes('Email/set')) await route.abort(); + else await route.continue(); + }); + let prompted = false; + popup.on('dialog', (dialog) => { prompted = true; void dialog.accept(); }); + const closed = popup.waitForEvent('close'); + await popup.close({ runBeforeUnload: true }); + await closed; + expect(prompted).toBe(false); + await expect(toast(page, /save/i)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('Continue draft').first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(typed).first()).toBeVisible(); + await page.context().unroute('**/jmap/**'); + await page.context().close(); + }); + + test('editing a draft from a message window opens a live composer, and only then closes', async ({ browser }) => { + const subject = `Edit ${RUN}`; + const alice = await jmapAs(ENT.alice); + const draftId = await alice.createDraft(subject, ENT.bob); + const page = await appPage(browser, STANDALONE); + const drafts = await alice.mailboxByRole('drafts'); + await page.goto(`/mail/folder/${drafts!.id}`); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 }); + await expect(row(page, subject)).toBeVisible({ timeout: 60_000 }); + const message = await openPopout(page, async () => { + await row(page, subject).click({ button: 'right' }); + await page.getByTestId('ctx-open-window').click(); + }); + await expect(message.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 }); + const closed = message.waitForEvent('close'); + const composer = await openPopout(message, async () => { + await message.getByRole('button', { name: /^Edit/ }).first().click(); + }); + await expect(composer.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject, { timeout: 60_000 }); + await closed; + // Live: it still saves over the same draft, with the window that opened it gone. + await composer.locator('[data-testid="composer-subject"]').first().fill(`${subject} edited`); + await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 20_000, intervals: POLL }).toBe(1); + await expect.poll(async () => (await draftsCalled(subject)).filter((draft) => draft.id === draftId).length, { timeout: 20_000, intervals: POLL }).toBe(0); + await page.context().close(); + }); +}); + +test.describe('the attachment bar', () => { + test('stays one row in a narrow pane, with both buttons in reach', async ({ browser }) => { + const subject = `Narrow ${RUN}`; + await messageWithAttachments(subject); + const page = await appPage(browser); + await stubDirectoryPicker(page); + await openInMain(page, subject); + const save = page.locator('[data-testid="attachments-save-to-folder"]').first(); + await expect(save).toBeVisible({ timeout: 30_000 }); + await page.setViewportSize({ width: 900, height: 900 }); + const pane = page.locator('[data-testid="reader-attachments"]').first(); + await expect(pane).toBeVisible(); + const row = save.locator('xpath=..'); + const rowBox = await row.boundingBox(); + const saveBox = await save.boundingBox(); + expect(rowBox!.height).toBeLessThan(48); + expect(saveBox!.x + saveBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width + 1); + await expect(save).toHaveAttribute('aria-label', /folder/i); + await page.context().close(); + }); +}); + +test.describe('the desktop app\'s behaviour', () => { + test('plain-text mail can be selected, list rows can\'t', async ({ browser }) => { + const subject = `Selectable ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser, BRIDGE); + await openInMain(page, subject); + const body = page.locator('[data-plugin-source="email-body"]').first(); + await expect(body).toBeVisible({ timeout: 30_000 }); + expect(await body.evaluate((el) => getComputedStyle(el).userSelect)).toBe('text'); + expect(await row(page, subject).evaluate((el) => getComputedStyle(el).userSelect)).toBe('none'); + // A real selection, the way a user would make one. + await body.click({ clickCount: 3 }); + expect(await page.evaluate(() => window.getSelection()?.toString() ?? '')).toContain(`Body of ${subject}`); + await page.context().close(); + }); + + test('hangs on the bridge: a browser gets none of it', async ({ browser }) => { + const plain = await appPage(browser); + expect(await plain.locator('html').getAttribute('data-desktop')).toBeNull(); + const button = plain.locator('button').first(); + expect(await button.evaluate((el) => getComputedStyle(el).userSelect)).not.toBe('none'); + await plain.context().close(); + + const app = await appPage(browser, BRIDGE); + expect(await app.locator('html').getAttribute('data-desktop')).toBe(''); + const chrome = app.locator('button').first(); + expect(await chrome.evaluate((el) => getComputedStyle(el).userSelect)).toBe('none'); + expect(await app.evaluate(() => getComputedStyle(document.documentElement).overscrollBehaviorY)).toBe('none'); + await app.context().close(); + }); +}); diff --git a/lib/__tests__/desktop-window-sync.test.ts b/lib/__tests__/desktop-window-sync.test.ts new file mode 100644 index 000000000..7ff38a7fc --- /dev/null +++ b/lib/__tests__/desktop-window-sync.test.ts @@ -0,0 +1,158 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { desktopBootScript } from '@/lib/desktop/boot'; +import { startWindowSync } from '@/lib/desktop/window-sync'; +import { useLocaleStore } from '@/stores/locale-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useThemeStore } from '@/stores/theme-store'; + +/** What another window of this browser writing the store looks like here. */ +function writeFromAnotherWindow(key: string, state: Record<string, unknown>) { + const current = JSON.parse(localStorage.getItem(key) ?? '{"state":{},"version":0}'); + const value = JSON.stringify({ ...current, state: { ...current.state, ...state } }); + localStorage.setItem(key, value); + window.dispatchEvent(new StorageEvent('storage', { key, newValue: value })); +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('state across windows', () => { + let stop: () => void = () => {}; + + beforeEach(() => { + useThemeStore.setState({ theme: 'light' }); + useLocaleStore.setState({ locale: 'en' }); + }); + + afterEach(() => { + stop(); + stop = () => {}; + delete window.bulwarkDesktop; + window.history.replaceState(null, '', '/'); + localStorage.clear(); + document.documentElement.classList.remove('dark', 'light'); + }); + + function asPopout() { + window.history.replaceState(null, '', '/en/popout/compose'); + stop = startWindowSync(); + } + + it('follows a theme change without a reload, down to the page', async () => { + asPopout(); + writeFromAnotherWindow('theme-storage', { theme: 'dark' }); + await settle(); + expect(useThemeStore.getState().theme).toBe('dark'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); + + it('follows a theme pack change too, which rehydrating alone does not put on the page', async () => { + asPopout(); + const pack = useThemeStore.getState().installedThemes.find((theme) => theme.css); + expect(pack).toBeTruthy(); + writeFromAnotherWindow('theme-storage', { theme: 'light', activeThemeId: pack!.id }); + await settle(); + expect(document.querySelector('style[id*="theme"]')?.textContent).toContain(pack!.css); + }); + + it('follows a language change without a reload', async () => { + asPopout(); + writeFromAnotherWindow('locale-storage', { locale: 'de' }); + await settle(); + expect(useLocaleStore.getState().locale).toBe('de'); + }); + + it('follows a settings change without a reload', async () => { + asPopout(); + const before = useSettingsStore.getState().plainTextMode; + writeFromAnotherWindow('settings-storage', { plainTextMode: !before }); + await settle(); + expect(useSettingsStore.getState().plainTextMode).toBe(!before); + }); + + it('also runs in the desktop app\'s main window', async () => { + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: {} }; + stop = startWindowSync(); + writeFromAnotherWindow('locale-storage', { locale: 'fr' }); + await settle(); + expect(useLocaleStore.getState().locale).toBe('fr'); + }); + + it('leaves a browser tab as it was', async () => { + stop = startWindowSync(); + writeFromAnotherWindow('locale-storage', { locale: 'fr' }); + await settle(); + expect(useLocaleStore.getState().locale).toBe('en'); + }); +}); + +describe('the app-like behaviour', () => { + const css = readFileSync(join(process.cwd(), 'app/desktop.css'), 'utf8'); + const rules = css.replace(/\/\*[\s\S]*?\*\//g, '').match(/[^{}]+\{[^}]*\}/g) ?? []; + + it('hangs entirely on html[data-desktop], so browsers do not change', () => { + expect(rules.length).toBeGreaterThan(0); + for (const rule of rules) { + const selector = rule.slice(0, rule.indexOf('{')); + // Every selector of the list; commas inside :is() don't split it. + const parts = selector.split(/,(?![^(]*\))/); + for (const part of parts) expect(part.trim(), part).toMatch(/^html\[data-desktop\]/); + } + }); + + it('covers UI chrome and what the spec names', () => { + expect(css).toMatch(/user-select: none/); + expect(css).toMatch(/cursor: default/); + expect(css).toMatch(/overscroll-behavior: none/); + expect(css).toMatch(/-webkit-user-drag: none/); + }); + + it('keeps fields selectable and what the app drags draggable', () => { + expect(css).toMatch(/:is\(input, textarea, select, \[contenteditable="true"\][^{]*\{\s*-webkit-user-select: text;\s*user-select: text;/); + expect(css).toMatch(/\[draggable="true"\]\s*\{\s*-webkit-user-drag: element;/); + }); + + it('does not switch selection off for the whole page, so message content stays selectable', () => { + expect(css).not.toMatch(/html\[data-desktop\]( body)?\s*\{[^}]*user-select: none/); + }); + + it('matches list rows only, not the plain-text body that carries the same data attribute', () => { + // email-viewer.tsx and thread-conversation-view.tsx put data-email-id on the body. + for (const file of ['components/email/email-viewer.tsx', 'components/email/thread-conversation-view.tsx']) { + const source = readFileSync(join(process.cwd(), file), 'utf8').replace(/\r\n/g, '\n'); + expect(source, file).toMatch(/data-plugin-source="email-body"\n\s*data-email-id=/); + } + expect(css).not.toContain('data-email-id'); + }); + + it('keeps a plain-text body selectable wherever it sits', () => { + expect(css).toMatch(/\[data-plugin-source="email-body"\],\s*html\[data-desktop\] \[data-plugin-source="email-body"\] \*\s*\{\s*-webkit-user-select: text;\s*user-select: text;/); + }); + + function runBoot() { + new Function(desktopBootScript())(); + } + + afterEach(() => { + delete window.bulwarkDesktop; + document.documentElement.removeAttribute('data-desktop'); + }); + + it('marks the page before the first paint when the bridge is there', () => { + window.bulwarkDesktop = { version: 1, platform: 'win32' }; + runBoot(); + expect(document.documentElement.hasAttribute('data-desktop')).toBe(true); + }); + + it('does not mark a browser', () => { + runBoot(); + expect(document.documentElement.hasAttribute('data-desktop')).toBe(false); + }); + + it('is in the root layout\'s boot script', () => { + const layout = readFileSync(join(process.cwd(), 'app/(main)/layout.tsx'), 'utf8'); + expect(layout).toContain('${desktopBootScript()}'); + expect(layout).toContain('../desktop.css'); + }); +}); diff --git a/lib/__tests__/fixtures/fake-broadcast-channel.ts b/lib/__tests__/fixtures/fake-broadcast-channel.ts new file mode 100644 index 000000000..fd683cbe9 --- /dev/null +++ b/lib/__tests__/fixtures/fake-broadcast-channel.ts @@ -0,0 +1,26 @@ +/** + * A same-process BroadcastChannel for tests: what one instance posts reaches + * every other open instance of that name, on a later task, never the sender. + */ +export class FakeBroadcastChannel { + static open = new Set<FakeBroadcastChannel>(); + static reset() { + FakeBroadcastChannel.open.clear(); + } + onmessage: ((event: MessageEvent) => void) | null = null; + constructor(public name: string) { + FakeBroadcastChannel.open.add(this); + } + postMessage(data: unknown) { + const copy = JSON.parse(JSON.stringify(data)); + for (const other of [...FakeBroadcastChannel.open]) { + if (other === this || other.name !== this.name) continue; + setTimeout(() => { + if (FakeBroadcastChannel.open.has(other)) other.onmessage?.({ data: copy } as MessageEvent); + }, 0); + } + } + close() { + FakeBroadcastChannel.open.delete(this); + } +} diff --git a/lib/__tests__/lite-scripts.test.ts b/lib/__tests__/lite-scripts.test.ts index 1262e7206..9661323cb 100644 --- a/lib/__tests__/lite-scripts.test.ts +++ b/lib/__tests__/lite-scripts.test.ts @@ -42,6 +42,7 @@ import { inlineScriptHashes, installStalwartFetchShim, isReservedStalwartSegment, + LITE_REMOVED_PATHS, listZipEntries, makeBuildId, normalizeBasePath, @@ -59,7 +60,6 @@ import { stalwartRewriteIndex, stalwartZipEntryProblems, unexpectedEngineFiles, - LITE_REMOVED_PATHS, unexpectedApiStrings, withScriptHashCsp, } from '../../scripts/lite/lib.mjs'; @@ -381,6 +381,8 @@ describe('prepare / postbuild / verify against a fake checkout', () => { expect(plan).toContain('proxy.ts'); expect(plan).toContain('app/api'); expect(plan).toContain('app/(main)/admin'); + // A static export can't list every message id; Lite doesn't offer pop-outs. + expect(LITE_REMOVED_PATHS).toContain('app/(main)/[locale]/popout'); expect(plan).not.toContain('app/(main)/[locale]/mail/[[...segments]]/page.tsx'); expect(plan).not.toContain('scripts/lite/smoke/lite.spec.ts'); } finally { diff --git a/lib/__tests__/oauth-token-route.test.ts b/lib/__tests__/oauth-token-route.test.ts index 1c225faf9..0170c197b 100644 --- a/lib/__tests__/oauth-token-route.test.ts +++ b/lib/__tests__/oauth-token-route.test.ts @@ -170,6 +170,21 @@ describe('oauth token route - access token cache (#552)', () => { expect(cookieStore.get('jmap_rt')).toBeTruthy(); }); + it('names a real invalid_grant, and only that, for the client to wipe on', async () => { + const refuse = (status: number, body: string) => fetchMock.mockResolvedValueOnce(new Response(body, { status })); + cookieStore.set('jmap_rt', 'refresh-token'); + refuse(400, JSON.stringify({ error: 'invalid_grant' })); + expect(await callPut({ force: 'true' })).toMatchObject({ status: 401, body: { error: 'invalid_grant' } }); + + for (const body of [JSON.stringify({ error: 'invalid_client' }), JSON.stringify({ error: 'invalid_request', error_description: 'Token is not valid yet' }), '<html>Forbidden</html>']) { + cookieStore.set('jmap_rt', 'refresh-token'); + refuse(403, body); + const { status, body: answer } = await callPut({ force: 'true' }); + expect(status).toBe(401); + expect(answer.error).not.toBe('invalid_grant'); + } + }); + it('answers 503 when the token endpoint is out of reach, which the client waits out', async () => { cookieStore.set('jmap_rt', 'refresh-token'); fetchMock.mockRejectedValue(new TypeError('fetch failed')); diff --git a/lib/__tests__/popout-windows.test.ts b/lib/__tests__/popout-windows.test.ts new file mode 100644 index 000000000..f3675c552 --- /dev/null +++ b/lib/__tests__/popout-windows.test.ts @@ -0,0 +1,351 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeBroadcastChannel } from './fixtures/fake-broadcast-channel'; +import { isPopoutPath, mainWindowStorage } from '@/lib/popout/window-role'; + +// Pop-out windows: which paths are one, when they are on offer, how they open, +// and how a draft moves to one. + +function stubDisplay({ standalone = true, fine = true }: { standalone?: boolean; fine?: boolean } = {}) { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query.includes('pointer: fine') ? fine : query.includes('display-mode: standalone') ? standalone : false, + addEventListener() {}, + removeEventListener() {}, + })); + window.matchMedia = globalThis.matchMedia; +} + +beforeEach(() => { + FakeBroadcastChannel.reset(); + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + Object.defineProperty(navigator, 'locks', { value: { request: vi.fn() }, configurable: true }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + delete window.bulwarkDesktop; + Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true }); + window.history.replaceState(null, '', '/'); +}); + +describe('isPopoutPath', () => { + it('knows the two routes, with or without locale and mount prefix', () => { + for (const path of ['/popout/compose', '/en/popout/compose', '/zh-TW/popout/message/abc', '/webmail/de/popout/message/abc', '/popout/compose/']) { + expect(isPopoutPath(path), path).toBe(true); + } + }); + + it('does not take other pages for one', () => { + // A folder in Files can have any name, and a mail folder too. + for (const path of ['/', '/mail', '/files/popout/compose', '/en/mail/folder/popout', '/popout', '/popout/message', '/popout/message/a/b', '/en/files/popout/message/x', '/files/en/popout/compose', '/some/folder/popout/compose']) { + expect(isPopoutPath(path), path).toBe(false); + } + }); +}); + +describe('isPopoutPath under a mount named like a page (NEXT_PUBLIC_BASE_PATH)', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('takes the mount off first', async () => { + vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/mail'); + vi.resetModules(); + const { isPopoutPath: underMail } = await import('@/lib/popout/window-role'); + expect(underMail('/mail/popout/compose')).toBe(true); + expect(underMail('/mail/de/popout/message/abc')).toBe(true); + // Under that mount, its files page stays a files page. + expect(underMail('/mail/files/popout/compose')).toBe(false); + }); +}); + +describe('mainWindowStorage', () => { + it('lets a main window write, and a pop-out only read', () => { + localStorage.setItem('account-registry', 'before'); + const storage = mainWindowStorage(); + + storage.setItem('account-registry', 'main'); + expect(localStorage.getItem('account-registry')).toBe('main'); + + window.history.replaceState(null, '', '/en/popout/compose'); + expect(storage.getItem('account-registry')).toBe('main'); + storage.setItem('account-registry', 'popout'); + storage.removeItem('account-registry'); + expect(localStorage.getItem('account-registry')).toBe('main'); + }); +}); + +describe('popoutsAvailable', () => { + async function available() { + const { popoutsAvailable } = await import('@/lib/popout/support'); + return popoutsAvailable(); + } + + it('is off in a browser tab: the app behaves as it always has', async () => { + stubDisplay({ standalone: false }); + expect(await available()).toBe(false); + }); + + it('is on in an installed app on a device with a pointer', async () => { + stubDisplay(); + expect(await available()).toBe(true); + }); + + it('is off in an installed app on a touch device, where window.open leaves the app', async () => { + stubDisplay({ fine: false }); + expect(await available()).toBe(false); + }); + + it('asks the bridge too: display-mode is `browser` in Electron', async () => { + stubDisplay({ standalone: false }); + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: {} }; + expect(await available()).toBe(true); + }); + + it('is off without Web Locks or BroadcastChannel, which keep the session safe across windows', async () => { + stubDisplay(); + Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true }); + expect(await available()).toBe(false); + Object.defineProperty(navigator, 'locks', { value: { request: vi.fn() }, configurable: true }); + vi.stubGlobal('BroadcastChannel', undefined); + expect(await available()).toBe(false); + }); + + it('is off where sessions can\'t be sealed: a page not in a secure context has no crypto.subtle', async () => { + stubDisplay(); + expect(await available()).toBe(true); + vi.stubGlobal('crypto', {}); + expect(await available()).toBe(false); + }); + + it('is off in Lite, which has no such route', async () => { + vi.resetModules(); + vi.doMock('@/lib/lite', async (original) => ({ ...await original<object>(), IS_LITE: true })); + stubDisplay(); + const { popoutsAvailable } = await import('@/lib/popout/support'); + expect(popoutsAvailable()).toBe(false); + vi.doUnmock('@/lib/lite'); + vi.resetModules(); + }); +}); + +describe('opening windows', () => { + it('opens a message as a popup in the app, with its login and account', async () => { + const { openMessageWindow } = await import('@/lib/popout/open'); + const open = vi.spyOn(window, 'open').mockReturnValue({ focus: vi.fn(), closed: false } as unknown as Window); + expect(openMessageWindow({ emailId: 'm/1', localAccountId: 'login-b', ownerAccountId: 'group' })).toBe(true); + const [url, name, features] = open.mock.calls[0]; + expect(url).toBe('/popout/message/m%2F1?account=login-b&owner=group'); + expect(name).toBe('bulwark-message-m_1'); + expect(features).toMatch(/^popup=yes,width=\d+,height=\d+,left=\d+,top=\d+$/); + // In an installed app noopener sends the window to the system browser. + expect(features).not.toContain('noopener'); + }); + + it('focuses the window a message already has, without loading it again', async () => { + const { openMessageWindow } = await import('@/lib/popout/open'); + const focus = vi.fn(); + const open = vi.spyOn(window, 'open').mockReturnValue({ focus, closed: false } as unknown as Window); + openMessageWindow({ emailId: 'twice' }); + openMessageWindow({ emailId: 'twice' }); + expect(open).toHaveBeenCalledTimes(1); + expect(focus).toHaveBeenCalledTimes(2); + }); + + it('opens it again once that window was closed', async () => { + const { openMessageWindow } = await import('@/lib/popout/open'); + const first = { focus: vi.fn(), closed: false }; + const open = vi.spyOn(window, 'open').mockReturnValue(first as unknown as Window); + openMessageWindow({ emailId: 'reopened' }); + first.closed = true; + openMessageWindow({ emailId: 'reopened' }); + expect(open).toHaveBeenCalledTimes(2); + }); + + it('leaves it to the bridge when the bridge opens windows, under the same name', async () => { + const { openMessageWindow } = await import('@/lib/popout/open'); + const bridgeOpen = vi.fn(); + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { open: bridgeOpen } }; + const open = vi.spyOn(window, 'open'); + expect(openMessageWindow({ emailId: 'bridged' })).toBe(true); + expect(openMessageWindow({ emailId: 'bridged' })).toBe(true); + expect(open).not.toHaveBeenCalled(); + expect(bridgeOpen.mock.calls[0][1]).toMatchObject({ name: 'bulwark-message-bridged' }); + expect(bridgeOpen.mock.calls[1][1].name).toBe(bridgeOpen.mock.calls[0][1].name); + }); + + it('says false when a blocker stops the window', async () => { + const { openComposerWindow, openMessageWindow } = await import('@/lib/popout/open'); + vi.spyOn(window, 'open').mockReturnValue(null); + expect(openMessageWindow({ emailId: 'blocked' })).toBe(false); + expect(openComposerWindow()).toBeNull(); + }); +}); + +describe('moving a draft to a pop-out', () => { + const payload = { mode: 'reply' as const, initialData: { draftId: 'saved-3', subject: 'Hi' } as never }; + const settle = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms)); + + // The module keeps its channel while a handoff is pending. + beforeEach(() => vi.resetModules()); + + it('hands the draft to the window that asks, once it is ready, under the same draft id', async () => { + const { createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff'); + const handoff = createComposeHandoff()!; + const received = receiveComposeHandoff(handoff.id); + await settle(); + // The pop-out is open and waiting; the save hasn't finished. + let done = false; + void received.then(() => { done = true; }); + await settle(); + expect(done).toBe(false); + + void handoff.complete(payload); + await expect(received).resolves.toMatchObject({ mode: 'reply', initialData: { draftId: 'saved-3' } }); + }); + + it('lets the sender go only once the composer is on screen there', async () => { + const { acknowledgeComposeHandoff, createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff'); + const handoff = createComposeHandoff()!; + void receiveComposeHandoff(handoff.id); + await settle(); + let shown: boolean | undefined; + void handoff.complete(payload).then((result) => { shown = result; }); + await settle(); + expect(shown).toBeUndefined(); + acknowledgeComposeHandoff(handoff.id); + await settle(); + expect(shown).toBe(true); + }); + + it('tells the sender when the composer never shows, and it can still call it off', async () => { + const { createComposeHandoff, receiveComposeHandoff, watchHandoffCancel } = await import('@/lib/popout/compose-handoff'); + const handoff = createComposeHandoff()!; + void receiveComposeHandoff(handoff.id); + await settle(); + const cancelled = vi.fn(); + const stop = watchHandoffCancel(handoff.id, cancelled); + await expect(handoff.complete(payload, 20)).resolves.toBe(false); + handoff.cancel(); + await settle(); + expect(cancelled).toHaveBeenCalledTimes(1); + stop(); + }); + + it('keeps delivering when the message window that opened it is still there', async () => { + const { createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff'); + const handoff = createComposeHandoff()!; + // The draft is ready before the new window asks for it. + void handoff.complete(payload); + await settle(); + await expect(receiveComposeHandoff(handoff.id)).resolves.toMatchObject({ mode: 'reply' }); + }); + + it('tells the pop-out when the move is called off', async () => { + const { createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff'); + const handoff = createComposeHandoff()!; + const received = receiveComposeHandoff(handoff.id); + await settle(); + handoff.cancel(); + await expect(received).resolves.toBeNull(); + }); + + it('gives up when nothing comes in time', async () => { + const { receiveComposeHandoff } = await import('@/lib/popout/compose-handoff'); + await expect(receiveComposeHandoff('nobody-has-this', 10)).resolves.toBeNull(); + }); + + it('starts a reload of the pop-out from what it holds, with no opener needed', async () => { + const { rememberComposePayload, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff'); + rememberComposePayload('kept', payload); + FakeBroadcastChannel.reset(); + vi.stubGlobal('BroadcastChannel', undefined); + await expect(receiveComposeHandoff('kept')).resolves.toMatchObject({ initialData: { draftId: 'saved-3' } }); + }); +}); + +describe('asking a main window for the session', () => { + it('takes the first answer and goes on without one after the timeout', async () => { + const { openPopoutChannel, requestWindowSession } = await import('@/lib/popout/channel'); + const { sealFor } = await import('@/lib/popout/sealed'); + await expect(requestWindowSession({}, 10)).resolves.toBeNull(); + + const main = openPopoutChannel((message) => { + if (message.type === 'session-request') { + void sealFor(message.publicKey, { activeAccountId: 'a', sessions: [] }) + .then((sealed) => main?.post({ type: 'session-answer', requestId: message.requestId, sealed })); + } + }); + await expect(requestWindowSession({}, 1000)).resolves.toEqual({ activeAccountId: 'a', sessions: [] }); + main?.close(); + }); + + it('puts no credential on the channel: a listener hears neither the header nor the refused one', async () => { + const { openPopoutChannel, requestWindowSession } = await import('@/lib/popout/channel'); + const { sealFor } = await import('@/lib/popout/sealed'); + const secret = 'Bearer very-secret-token'; + const refused = 'Bearer refused-secret-token'; + const heard: unknown[] = []; + const eavesdropper = openPopoutChannel((message) => heard.push(message)); + const main = openPopoutChannel((message) => { + if (message.type !== 'session-request') return; + void sealFor(message.publicKey, { activeAccountId: 'a', sessions: [{ accountId: 'a', authHeader: secret }] }) + .then((sealed) => main?.post({ type: 'session-answer', requestId: message.requestId, sealed })); + }); + const answer = await requestWindowSession({ accountId: 'a', rejectedAuthHeader: refused }, 1000); + expect(answer?.sessions[0].authHeader).toBe(secret); + expect(heard.length).toBeGreaterThanOrEqual(2); + expect(JSON.stringify(heard)).not.toContain('secret'); + main?.close(); + eavesdropper?.close(); + }); + + it('a listener can\'t open an answer meant for another window', async () => { + const { createSessionOpener, sealFor } = await import('@/lib/popout/sealed'); + const asker = await createSessionOpener(); + const other = await createSessionOpener(); + const sealed = await sealFor(asker.publicKey, { secret: true }); + await expect(asker.open(sealed)).resolves.toEqual({ secret: true }); + await expect(other.open(sealed)).rejects.toThrow(); + }); +}); + +describe('text a closing pop-out could not save', () => { + it('waits for the mail view, marked unsaved, oldest first', async () => { + vi.resetModules(); + sessionStorage.clear(); + const { keepRescuedDraft, onRescuedDraft, takeRescuedDraft } = await import('@/lib/popout/rescued-drafts'); + const told = vi.fn(); + const stop = onRescuedDraft(told); + keepRescuedDraft({ subject: 'First' } as never); + keepRescuedDraft({ subject: 'Second' } as never); + stop(); + expect(told).toHaveBeenCalledTimes(2); + expect(takeRescuedDraft()).toMatchObject({ subject: 'First', unsaved: true }); + expect(takeRescuedDraft()).toMatchObject({ subject: 'Second' }); + expect(takeRescuedDraft()).toBeNull(); + }); + + it('survives a reload of the main window', async () => { + vi.resetModules(); + sessionStorage.clear(); + const first = await import('@/lib/popout/rescued-drafts'); + first.keepRescuedDraft({ subject: 'Kept' } as never); + vi.resetModules(); + const second = await import('@/lib/popout/rescued-drafts'); + expect(second.takeRescuedDraft()).toMatchObject({ subject: 'Kept' }); + }); +}); + +describe('every display-mode check knows the overlay', () => { + it('goes through lib/standalone-display.ts', () => { + for (const file of ['components/protocol/mailto-protocol-client.tsx', 'components/protocol/webcal-protocol-client.tsx', 'components/protocol/protocol-launch-handler-provider.tsx', 'lib/protocol-handlers/session.ts']) { + const source = readFileSync(join(process.cwd(), file), 'utf8'); + expect(source, file).not.toContain('display-mode: standalone'); + expect(source, file).toContain('isStandaloneDisplayMode'); + } + }); +}); diff --git a/lib/__tests__/save-to-folder.test.ts b/lib/__tests__/save-to-folder.test.ts new file mode 100644 index 000000000..f2090a813 --- /dev/null +++ b/lib/__tests__/save-to-folder.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { canPickFolder, createUniqueNamer, isPickerCancelled, saveFilesToFolder, type FolderHandle } from '@/lib/attachments/save-to-folder'; + +class FakeFolder implements FolderHandle { + name = 'Downloads'; + files = new Map<string, string>(); + failWriteOf: string | null = null; + removed: string[] = []; + refused = new Set<string>(); + + async getFileHandle(name: string, options?: { create?: boolean }) { + // What Windows and Chromium's File System Access refuse. + const stem = name.split('.')[0].toLowerCase(); + if (/^(con|prn|aux|nul|com\d|lpt\d)$/.test(stem) + || /\.(lnk|url|scf|local)$/i.test(name) + || name.toLowerCase() === 'desktop.ini' + || new TextEncoder().encode(name).length > 255 + || this.refused.has(name)) { + throw new TypeError(`Name is not allowed: ${name}`); + } + if (!this.files.has(name)) { + if (!options?.create) throw Object.assign(new Error('missing'), { name: 'NotFoundError' }); + this.files.set(name, ''); + } + return { + createWritable: async () => ({ + write: async (data: unknown) => { + if (this.failWriteOf === name) throw Object.assign(new Error('disk full'), { name: 'QuotaExceededError' }); + this.files.set(name, String(data)); + }, + close: async () => {}, + abort: async () => {}, + }), + }; + } + + async removeEntry(name: string) { + this.removed.push(name); + this.files.delete(name); + } +} + +const item = (name: string, content: string | null = name) => ({ name, read: async () => content as unknown as BufferSource | null }); +const always = () => true; + +afterEach(() => { + vi.unstubAllGlobals(); + delete (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker; +}); + +describe('saveFilesToFolder', () => { + it('writes each file and says how many', async () => { + const folder = new FakeFolder(); + const result = await saveFilesToFolder(folder, [item('a.txt'), item('b.txt')], always); + expect(result).toEqual({ folder: 'Downloads', total: 2, saved: 2, skipped: 0, stopped: null }); + expect(folder.files.get('a.txt')).toBe('a.txt'); + }); + + it('gives same-named attachments their own files', async () => { + const folder = new FakeFolder(); + await saveFilesToFolder(folder, [item('scan.pdf', 'one'), item('scan.pdf', 'two'), item('scan.pdf', 'three')], always); + expect([...folder.files.entries()]).toEqual([['scan.pdf', 'one'], ['scan (1).pdf', 'two'], ['scan (2).pdf', 'three']]); + }); + + it('never overwrites a file already in the folder', async () => { + const folder = new FakeFolder(); + folder.files.set('report.pdf', 'precious'); + await saveFilesToFolder(folder, [item('report.pdf', 'new')], always); + expect(folder.files.get('report.pdf')).toBe('precious'); + expect(folder.files.get('report (1).pdf')).toBe('new'); + }); + + it('stops at a failed write, keeps what is saved, and leaves no empty file', async () => { + const folder = new FakeFolder(); + folder.failWriteOf = 'b.txt'; + const result = await saveFilesToFolder(folder, [item('a.txt'), item('b.txt'), item('c.txt')], always); + expect(result).toMatchObject({ saved: 1, total: 3, stopped: 'write' }); + expect([...folder.files.keys()]).toEqual(['a.txt']); + expect(folder.removed).toEqual(['b.txt']); + }); + + it('skips a file that cannot be fetched and goes on, as the zip does', async () => { + const folder = new FakeFolder(); + const broken = { name: 'gone.bin', read: async () => { throw new Error('404'); } }; + const result = await saveFilesToFolder(folder, [item('a.txt'), broken, item('c.txt')], always); + expect(result).toMatchObject({ saved: 2, skipped: 1, stopped: null }); + }); + + it('asks the policy before every file, and a policy turning on stops the rest', async () => { + const folder = new FakeFolder(); + let asked = 0; + // On for the first file's two checks, then off. + const allowed = () => ++asked <= 2; + const read = vi.fn(async () => 'x' as unknown as BufferSource); + const result = await saveFilesToFolder(folder, [{ name: 'a', read }, { name: 'b', read }], allowed); + expect(result).toMatchObject({ saved: 1, stopped: 'policy' }); + expect(read).toHaveBeenCalledTimes(1); + expect([...folder.files.keys()]).toEqual(['a']); + }); + + it('writes nothing fetched while the policy turned on', async () => { + const folder = new FakeFolder(); + let on = true; + const read = vi.fn(async () => { on = false; return 'x' as unknown as BufferSource; }); + const result = await saveFilesToFolder(folder, [{ name: 'a', read }], () => on); + expect(result).toMatchObject({ saved: 0, stopped: 'policy' }); + expect(folder.files.size).toBe(0); + }); + + it('keeps a sender\'s path out of the file name', async () => { + const folder = new FakeFolder(); + await saveFilesToFolder(folder, [item('../../evil.exe'), item('C:\\Windows\\win.ini'), item('a:b?.txt'), item('...')], always); + expect([...folder.files.keys()]).toEqual(['evil.exe', 'win.ini', 'a_b_.txt', 'attachment']); + }); +}); + +describe('names a file system or the shell keeps for itself', () => { + it('renames them, so the attachment is still saved', async () => { + const folder = new FakeFolder(); + const names = ['CON', 'NUL.txt', 'com1.tar.gz', 'desktop.ini', 'x.lnk', 'x.url', 'x.scf', 'X.LNK']; + const result = await saveFilesToFolder(folder, names.map((name) => item(name)), always); + expect(result).toMatchObject({ saved: names.length, skipped: 0, stopped: null }); + expect([...folder.files.keys()]).toEqual(['_CON', '_NUL.txt', '_com1.tar.gz', '_desktop.ini', 'x.lnk.txt', 'x.url.txt', 'x.scf.txt', 'X.LNK.txt']); + }); + + it('leaves names that only look like them', async () => { + const folder = new FakeFolder(); + await saveFilesToFolder(folder, ['context.txt', 'console.log', 'nullable.ts', 'lnk.pdf'].map((name) => item(name)), always); + expect([...folder.files.keys()]).toEqual(['context.txt', 'console.log', 'nullable.ts', 'lnk.pdf']); + }); + + it('shortens a name over the limit, in bytes, keeping the extension and whole characters', async () => { + const folder = new FakeFolder(); + const long = `${'ü'.repeat(200)}.pdf`; + const result = await saveFilesToFolder(folder, [item(long), item(long)], always); + expect(result.saved).toBe(2); + const [first, second] = [...folder.files.keys()]; + expect(first.endsWith('.pdf')).toBe(true); + expect(first).not.toContain('\uFFFD'); + expect(new TextEncoder().encode(first).length).toBeLessThanOrEqual(255); + expect(new TextEncoder().encode(second).length).toBeLessThanOrEqual(255); + expect(second.endsWith(' (1).pdf')).toBe(true); + }); + + it('skips one name the folder still refuses and goes on to the rest', async () => { + const folder = new FakeFolder(); + folder.refused.add('odd.bin'); + const result = await saveFilesToFolder(folder, [item('a.txt'), item('odd.bin'), item('c.txt')], always); + expect(result).toMatchObject({ saved: 2, skipped: 1, stopped: null }); + expect([...folder.files.keys()]).toEqual(['a.txt', 'c.txt']); + }); +}); + +describe('the helpers', () => { + it('sees the picker only where the browser has it', () => { + expect(canPickFolder()).toBe(false); + (window as unknown as { showDirectoryPicker: unknown }).showDirectoryPicker = () => {}; + expect(canPickFolder()).toBe(true); + }); + + it('tells a closed picker from a failure', () => { + expect(isPickerCancelled(Object.assign(new Error('x'), { name: 'AbortError' }))).toBe(true); + expect(isPickerCancelled(Object.assign(new Error('x'), { name: 'SecurityError' }))).toBe(false); + expect(isPickerCancelled(null)).toBe(false); + }); + + it('numbers zip entries like before', () => { + const name = createUniqueNamer(); + expect(['a.txt', 'a.txt', 'a.txt', '', ''].map(name)).toEqual(['a.txt', 'a (1).txt', 'a (2).txt', 'attachment', 'attachment (1)']); + }); +}); diff --git a/lib/__tests__/standalone-guard.test.ts b/lib/__tests__/standalone-guard.test.ts index dbc69e792..347d8f526 100644 --- a/lib/__tests__/standalone-guard.test.ts +++ b/lib/__tests__/standalone-guard.test.ts @@ -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(); diff --git a/lib/__tests__/sw-offline-shell.test.ts b/lib/__tests__/sw-offline-shell.test.ts index 193067bc1..7785e6c28 100644 --- a/lib/__tests__/sw-offline-shell.test.ts +++ b/lib/__tests__/sw-offline-shell.test.ts @@ -65,6 +65,45 @@ describe('service worker with offline mode on', () => { expect(await deep!.text()).toBe('<html>mail page</html>'); }); + it('keeps pop-out pages, and starts a message it never saw from the page kept for it', async () => { + const caches = new FakeCaches(); + await shellOn(caches, true); + let online = true; + const fetchMock = vi.fn(async (url: string) => { + if (!online) throw new TypeError('Failed to fetch'); + return sameOrigin(`<html>${new URL(typeof url === 'string' ? url : (url as Request).url).pathname}</html>`); + }); + const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock as never }); + await settle(); + for (const path of ['/popout/compose', '/popout/message/_', '/']) { + const page = await worker.request({ url: `${ORIGIN}${path}`, mode: 'navigate' }); + expect(page, path).not.toBeNull(); + } + await settle(); + + online = false; + const message = await worker.request({ url: `${ORIGIN}/popout/message/never-seen`, mode: 'navigate' }); + expect(await message!.text()).toBe('<html>/popout/message/_</html>'); + const compose = await worker.request({ url: `${ORIGIN}/popout/compose?handoff=x`, mode: 'navigate' }); + expect(await compose!.text()).toBe('<html>/popout/compose</html>'); + }); + + it('never puts the whole app into a pop-out\'s window', async () => { + const caches = new FakeCaches(); + await shellOn(caches, true); + let online = true; + const fetchMock = vi.fn(async () => { + if (!online) throw new TypeError('Failed to fetch'); + return sameOrigin('<html>the whole app</html>'); + }); + const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock }); + await settle(); + await worker.request({ url: `${ORIGIN}/`, mode: 'navigate' }); + await settle(); + online = false; + await expect(worker.request({ url: `${ORIGIN}/popout/compose`, mode: 'navigate' })).rejects.toThrow('Failed to fetch'); + }); + it('serves a chunk of an older build from that build\'s cache, and keeps new ones per build', async () => { const caches = new FakeCaches(); await shellOn(caches, true); diff --git a/lib/__tests__/token-lock.test.ts b/lib/__tests__/token-lock.test.ts new file mode 100644 index 000000000..b3ff36106 --- /dev/null +++ b/lib/__tests__/token-lock.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { tokenLockName, withTokenLock } from '@/lib/auth/token-lock'; + +/** Web Locks with one holder at a time and waiters in order, like the browser's. */ +function fakeLocks() { + const queues = new Map<string, Promise<unknown>>(); + const held = new Set<string>(); + const names: string[] = []; + return { + names, + request(name: string, options: { signal?: AbortSignal; ifAvailable?: boolean }, callback: (lock: unknown) => unknown) { + names.push(name); + if (options.ifAvailable && held.has(name)) return Promise.resolve(callback(null)); + const previous = queues.get(name) ?? Promise.resolve(); + const run = new Promise((resolve, reject) => { + let aborted = false; + options.signal?.addEventListener('abort', () => { + aborted = true; + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }); + void previous.catch(() => {}).then(() => { + if (aborted) return; + held.add(name); + Promise.resolve(callback({})).then(resolve, reject).finally(() => held.delete(name)); + }); + }); + queues.set(name, run.catch(() => {})); + return run; + }, + }; +} + +function stubLocks(locks: unknown) { + Object.defineProperty(navigator, 'locks', { value: locks, configurable: true }); +} + +afterEach(() => { + stubLocks(undefined); + vi.restoreAllMocks(); +}); + +describe('withTokenLock', () => { + it('runs one renewal per slot at a time, so the second sends the token the first wrote', async () => { + stubLocks(fakeLocks()); + const order: string[] = []; + let releaseFirst = () => {}; + const first = withTokenLock(1, () => new Promise<string>((resolve) => { + order.push('first starts'); + releaseFirst = () => { + order.push('first ends'); + resolve('a'); + }; + })); + const second = withTokenLock(1, async () => { + order.push('second starts'); + return 'b'; + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(order).toEqual(['first starts']); + releaseFirst(); + await expect(Promise.all([first, second])).resolves.toEqual(['a', 'b']); + expect(order).toEqual(['first starts', 'first ends', 'second starts']); + }); + + it('keeps slots apart: each login has its own refresh token', async () => { + const locks = fakeLocks(); + stubLocks(locks); + await withTokenLock(0, async () => 'x'); + await withTokenLock(2, async () => 'y'); + expect(new Set(locks.names)).toEqual(new Set([tokenLockName(0), tokenLockName(2)])); + expect(tokenLockName(0)).not.toBe(tokenLockName(2)); + }); + + it('runs at once without Web Locks', async () => { + await expect(withTokenLock(1, async () => 'done')).resolves.toBe('done'); + }); + + it('goes ahead when another window holds the lock too long, rather than never renewing', async () => { + stubLocks(fakeLocks()); + void withTokenLock(1, () => new Promise(() => {})); + await new Promise((resolve) => setTimeout(resolve, 5)); + await expect(withTokenLock(1, async () => 'went ahead', { waitMs: 10 })).resolves.toBe('went ahead'); + }); + + it('says when it had to wait for another window, and when it did not', async () => { + stubLocks(fakeLocks()); + const turns: boolean[] = []; + let release = () => {}; + const first = withTokenLock(1, ({ contended }) => { + turns.push(contended); + return new Promise<void>((resolve) => { release = resolve; }); + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + const second = withTokenLock(1, async ({ contended }) => { turns.push(contended); }); + await new Promise((resolve) => setTimeout(resolve, 5)); + release(); + await Promise.all([first, second]); + expect(turns).toEqual([false, true]); + await withTokenLock(1, async ({ contended }) => { turns.push(contended); }); + expect(turns).toEqual([false, true, false]); + }); + + it('does nothing when the caller calls it off during the wait: a sign-out', async () => { + stubLocks(fakeLocks()); + void withTokenLock(1, () => new Promise(() => {})); + await new Promise((resolve) => setTimeout(resolve, 5)); + const signOut = new AbortController(); + const work = vi.fn(async () => 'renewed'); + const queued = withTokenLock(1, work, { signal: signOut.signal }); + await new Promise((resolve) => setTimeout(resolve, 5)); + signOut.abort(); + await expect(queued).rejects.toThrow(); + expect(work).not.toHaveBeenCalled(); + }); + + it('hands the work\'s own error to the caller and does not run it twice', async () => { + stubLocks(fakeLocks()); + const work = vi.fn(async () => { throw new TypeError('Failed to fetch'); }); + await expect(withTokenLock(1, work)).rejects.toThrow('Failed to fetch'); + expect(work).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/__tests__/window-title-bar.test.tsx b/lib/__tests__/window-title-bar.test.tsx new file mode 100644 index 000000000..b0d4bb11b --- /dev/null +++ b/lib/__tests__/window-title-bar.test.tsx @@ -0,0 +1,130 @@ +import { act, cleanup, render } from '@testing-library/react'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { WindowTitleBar } from '@/components/layout/window-title-bar'; +import { TITLEBAR_INSET_VAR, readTitlebarArea } from '@/lib/window-controls-overlay'; + +class FakeOverlay extends EventTarget { + visible = true; + rect = { x: 0, y: 0, width: 867, height: 40 }; + getTitlebarAreaRect() { + return this.rect; + } + change(next: Partial<FakeOverlay>) { + Object.assign(this, next); + this.dispatchEvent(new Event('geometrychange')); + } +} + +function installOverlay(overlay: FakeOverlay | undefined) { + Object.defineProperty(navigator, 'windowControlsOverlay', { value: overlay, configurable: true }); +} + +const inset = () => document.documentElement.style.getPropertyValue(TITLEBAR_INSET_VAR); +const strip = () => document.querySelector<HTMLElement>('[data-window-title-bar]'); + +afterEach(() => { + cleanup(); + installOverlay(undefined); + document.documentElement.style.removeProperty(TITLEBAR_INSET_VAR); +}); + +describe('the title bar', () => { + it('shows nothing and touches nothing without the API', () => { + installOverlay(undefined); + expect(readTitlebarArea()).toBeNull(); + render(<WindowTitleBar />); + expect(strip()).toBeNull(); + expect(inset()).toBe(''); + }); + + it('shows nothing when the overlay is there but hidden', () => { + const overlay = new FakeOverlay(); + overlay.visible = false; + installOverlay(overlay); + render(<WindowTitleBar />); + expect(strip()).toBeNull(); + expect(inset()).toBe(''); + }); + + it('draws the strip next to the controls and pushes the page down', () => { + installOverlay(new FakeOverlay()); + document.title = 'Inbox - Mail'; + render(<WindowTitleBar />); + const bar = strip()!; + expect(bar.style.width).toBe('867px'); + expect(bar.style.height).toBe('40px'); + expect(bar.textContent).toBe('Inbox - Mail'); + expect(bar.classList.contains('window-title-bar')).toBe(true); + expect(inset()).toBe('40px'); + }); + + it('stays out of the accessibility tree and the tab order', () => { + installOverlay(new FakeOverlay()); + render(<WindowTitleBar />); + expect(strip()!.getAttribute('aria-hidden')).toBe('true'); + expect(strip()!.querySelector('a, button, input, [tabindex]')).toBeNull(); + }); + + it('follows geometrychange, and takes everything back when the overlay goes', () => { + const overlay = new FakeOverlay(); + installOverlay(overlay); + render(<WindowTitleBar />); + act(() => overlay.change({ rect: { x: 72, y: 0, width: 500, height: 32 } })); + expect(strip()!.style.left).toBe('72px'); + expect(inset()).toBe('32px'); + act(() => overlay.change({ visible: false })); + expect(strip()).toBeNull(); + expect(inset()).toBe(''); + }); + + it('follows the window title as the surface changes it', async () => { + installOverlay(new FakeOverlay()); + document.title = 'First'; + render(<WindowTitleBar />); + await act(async () => { + document.title = 'Second'; + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(strip()!.textContent).toBe('Second'); + }); +}); + +describe('the page roots and the stylesheet', () => { + const root = process.cwd(); + const css = readFileSync(join(root, 'app/globals.css'), 'utf8'); + + it('is 0 without an overlay, and exactly the old safe area then', () => { + expect(css).toContain('--titlebar-inset: env(titlebar-area-height, 0px);'); + expect(css).toContain('--app-top-inset: max(env(safe-area-inset-top), var(--titlebar-inset));'); + }); + + it('does not key on the display-mode media query, which is false in Electron', () => { + expect(css).not.toMatch(/display-mode:\s*window-controls-overlay/); + }); + + it('makes the strip the part that drags the window', () => { + expect(css).toMatch(/\.window-title-bar\s*\{[^}]*app-region:\s*drag/); + }); + + function tsxFiles(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (name === '__tests__' || name === 'node_modules') return []; + return statSync(path).isDirectory() ? tsxFiles(path) : name.endsWith('.tsx') ? [path] : []; + }); + } + + it('leaves no page root or top bar on the bare safe area, skeletons included', () => { + const left = [...tsxFiles(join(root, 'components')), ...tsxFiles(join(root, 'app'))] + .filter((path) => /pt-\[(?:calc\([^\]]*)?env\(safe-area-inset-top/.test(readFileSync(path, 'utf8'))); + expect(left).toEqual([]); + }); + + it('gives each skeleton the same top inset as the page it stands in for', () => { + for (const file of ['components/boot-skeleton/shell.tsx', 'components/boot-skeleton/pro-skeleton.tsx', 'components/boot-skeleton/admin-skeleton.tsx', 'components/mail/mail-app.tsx']) { + expect(readFileSync(join(root, file), 'utf8'), file).toContain('pt-[var(--app-top-inset)]'); + } + }); +}); diff --git a/lib/account-utils.ts b/lib/account-utils.ts index c6cb10965..301333af0 100644 --- a/lib/account-utils.ts +++ b/lib/account-utils.ts @@ -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; } diff --git a/lib/admin/jmap-servers.ts b/lib/admin/jmap-servers.ts index 67319b54d..5bff4f136 100644 --- a/lib/admin/jmap-servers.ts +++ b/lib/admin/jmap-servers.ts @@ -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; diff --git a/lib/attachments/save-to-folder.ts b/lib/attachments/save-to-folder.ts new file mode 100644 index 000000000..2c075b06c --- /dev/null +++ b/lib/attachments/save-to-folder.ts @@ -0,0 +1,215 @@ +/** + * "Save all attachments to a folder", with the File System Access API. + * + * These writes don't pass saveBlobAs (lib/download-guard.ts), the backstop of + * the device policies, so the caller's `allowed` is asked before each file: + * a policy that turns on while the files are written stops the rest. + * + * The folder handle is an interface, not the DOM type, so the desktop bridge + * or a test can supply its own. + */ + +export interface WritableFile { + write(data: Blob | BufferSource): Promise<void>; + close(): Promise<void>; + abort?(): Promise<void>; +} + +export interface FolderFile { + createWritable(): Promise<WritableFile>; +} + +export interface FolderHandle { + name: string; + getFileHandle(name: string, options?: { create?: boolean }): Promise<FolderFile>; + removeEntry?(name: string): Promise<void>; +} + +type DirectoryPicker = (options?: { mode?: 'read' | 'readwrite'; id?: string; startIn?: string }) => Promise<FolderHandle>; + +/** Whether this browser can ask for a folder. Without it the zip download is all there is. */ +export function canPickFolder(): boolean { + return typeof window !== 'undefined' && typeof (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker === 'function'; +} + +/** + * Asks where to save. Call it straight from the click, before any await: the + * browser only shows the picker for a user action. Rejects with an AbortError + * when the user closes the picker (see isPickerCancelled). + */ +export function pickFolder(): Promise<FolderHandle> { + const picker = (window as unknown as { showDirectoryPicker: DirectoryPicker }).showDirectoryPicker; + return picker.call(window, { mode: 'readwrite', id: 'bulwark-attachments', startIn: 'downloads' }); +} + +export function isPickerCancelled(error: unknown): boolean { + return (error as { name?: unknown } | null)?.name === 'AbortError'; +} + +/** The name's nth alternative: " (n)" before the extension, so duplicates stay recognisable. */ +function numberedName(base: string, n: number): string { + if (n === 0) return base; + const dot = base.lastIndexOf('.'); + const stem = dot > 0 ? base.slice(0, dot) : base; + const ext = dot > 0 ? base.slice(dot) : ''; + return `${stem} (${n})${ext}`; +} + +/** Names unique within one batch, for the zip's entries. */ +export function createUniqueNamer(fallback = 'attachment'): (raw: string) => string { + const used = new Set<string>(); + return (raw) => { + const base = raw || fallback; + let n = 0; + while (used.has(numberedName(base, n))) n++; + const name = numberedName(base, n); + used.add(name); + return name; + }; +} + +/** Names Windows keeps for devices, with any extension: NUL.txt is NUL. */ +const RESERVED_STEM = /^(con|prn|aux|nul|clock\$|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])$/i; +/** Names and extensions the shell acts on, which browsers refuse to create. */ +const SHELL_NAMES = new Set(['desktop.ini', 'thumbs.db', 'autorun.inf']); +const SHELL_EXTENSIONS = new Set(['.lnk', '.url', '.scf', '.local', '.pif', '.shb', '.cpl', '.inf']); +/** A file system's limit is 255 bytes. Below it, leaving room for " (n)". */ +const MAX_NAME_BYTES = 240; + +function truncateName(name: string): string { + const encoder = new TextEncoder(); + if (encoder.encode(name).length <= MAX_NAME_BYTES) return name; + const dot = name.lastIndexOf('.'); + const ext = dot > 0 && encoder.encode(name.slice(dot)).length <= 32 ? name.slice(dot) : ''; + let room = MAX_NAME_BYTES - encoder.encode(ext).length; + let stem = ''; + // By code point, so a character is never cut in half. + for (const char of ext ? name.slice(0, dot) : name) { + room -= encoder.encode(char).length; + if (room < 0) break; + stem += char; + } + return stem.trimEnd() + ext; +} + +/** + * A name the folder will take: no path parts a sender could smuggle in, no + * characters a file system refuses, and nothing Windows or the shell treats as + * special. Renamed, so the attachment is still saved. + */ +function safeFileName(raw: string, fallback: string): string { + const leaf = raw.split(/[\\/]/).pop() ?? ''; + // eslint-disable-next-line no-control-regex + const cleaned = leaf.replace(/[<>:"|?*\x00-\x1f]/g, '_').replace(/[. ]+$/, '').trim(); + if (!cleaned || cleaned === '.' || cleaned === '..') return fallback; + const lower = cleaned.toLowerCase(); + const stem = lower.split('.')[0]; + const dot = lower.lastIndexOf('.'); + let safe = cleaned; + if (RESERVED_STEM.test(stem) || SHELL_NAMES.has(lower)) safe = `_${cleaned}`; + else if (dot > 0 && SHELL_EXTENSIONS.has(lower.slice(dot))) safe = `${cleaned}.txt`; + return truncateName(safe); +} + +/** The folder or the browser refused this name, which says nothing about the next file. */ +function isNameRefused(error: unknown): boolean { + const kind = (error as { name?: unknown } | null)?.name; + return kind === 'TypeError' || kind === 'SecurityError'; +} + +async function existsInFolder(folder: FolderHandle, name: string): Promise<boolean> { + try { + await folder.getFileHandle(name); + return true; + } catch (error) { + const kind = (error as { name?: unknown } | null)?.name; + if (kind === 'NotFoundError') return false; + // A sub-folder of that name: taken as well. + if (kind === 'TypeMismatchError') return true; + throw error; + } +} + +/** The first of the name's alternatives neither this batch nor the folder has. */ +async function freeFileName(folder: FolderHandle, base: string, claimed: ReadonlySet<string>): Promise<string> { + let n = 0; + while (claimed.has(numberedName(base, n)) || await existsInFolder(folder, numberedName(base, n))) n++; + return numberedName(base, n); +} + +export interface FolderSaveItem { + name: string; + /** The file's content, or null when it has none to save. Fetched only when its turn comes. */ + read: () => Promise<Blob | BufferSource | null>; +} + +export interface FolderSaveResult { + folder: string; + total: number; + saved: number; + /** Files that couldn't be read from the server, or whose name was refused; the rest still went. */ + skipped: number; + /** Why the run ended early, when it did. */ + stopped: 'policy' | 'write' | null; +} + +/** + * Writes the files one at a time. A file that can't be fetched or whose name is + * refused is skipped, like in the zip. A write that fails stops the run, because + * the next one would fail the same way (disk full, permission gone), and the + * result says how many made it. A file already in the folder is never overwritten. + */ +export async function saveFilesToFolder( + folder: FolderHandle, + items: readonly FolderSaveItem[], + allowed: () => boolean, +): Promise<FolderSaveResult> { + const result: FolderSaveResult = { folder: folder.name, total: items.length, saved: 0, skipped: 0, stopped: null }; + const claimed = new Set<string>(); + + for (const item of items) { + if (!allowed()) { + result.stopped = 'policy'; + return result; + } + + let data: Blob | BufferSource | null; + try { + data = await item.read(); + } catch { + data = null; + } + if (data === null) { + result.skipped += 1; + continue; + } + // The policy may have changed during the fetch, and nothing is on disk yet. + if (!allowed()) { + result.stopped = 'policy'; + return result; + } + + let name: string | null = null; + let writable: WritableFile | null = null; + try { + name = await freeFileName(folder, safeFileName(item.name, 'attachment'), claimed); + claimed.add(name); + const file = await folder.getFileHandle(name, { create: true }); + writable = await file.createWritable(); + await writable.write(data); + await writable.close(); + result.saved += 1; + } catch (error) { + await writable?.abort?.().catch(() => {}); + // getFileHandle created an empty file; don't leave it looking saved. + if (name) await folder.removeEntry?.(name).catch(() => {}); + if (isNameRefused(error)) { + result.skipped += 1; + continue; + } + result.stopped = 'write'; + return result; + } + } + return result; +} diff --git a/lib/auth/client-address.ts b/lib/auth/client-address.ts index 6de013819..c53c42635 100644 --- a/lib/auth/client-address.ts +++ b/lib/auth/client-address.ts @@ -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); } diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts index 50e053e35..a16fd7958 100644 --- a/lib/auth/crypto.ts +++ b/lib/auth/crypto.ts @@ -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 { diff --git a/lib/auth/token-lock.ts b/lib/auth/token-lock.ts new file mode 100644 index 000000000..6a4b6906e --- /dev/null +++ b/lib/auth/token-lock.ts @@ -0,0 +1,78 @@ +/** + * One token renewal at a time across the windows of this browser. + * + * Refresh tokens rotate, and the token route reads the current one from a + * cookie. Two windows renewing at once send the same token twice; on Stalwart + * 1.0 a superseded token reused past its retry window counts as a replay and + * revokes the whole grant. Held across the request, so the next window's + * request carries the cookie the first one's answer wrote. + * + * Without Web Locks, or after waiting too long for a window that hangs, the + * work runs anyway: never worse than having no lock. + */ + +interface LockManagerLike { + request<T>(name: string, options: { signal?: AbortSignal; ifAvailable?: boolean }, callback: (lock: unknown) => T | Promise<T>): Promise<T>; +} + +/** How long a renewal waits for another window's before going ahead. Above the route's own timeouts. */ +export const TOKEN_LOCK_WAIT_MS = 30_000; + +const NOT_HELD = Symbol('not held'); + +export interface TokenLockTurn { + /** + * Another window held the lock first, so it may have renewed already. A + * renewal that was going to force one should take the cached token instead: + * an identity provider can refuse a second one this soon (Rauthy `nbf`, #552). + */ + contended: boolean; +} + +function lockManager(): LockManagerLike | null { + const locks = typeof navigator !== 'undefined' ? (navigator as { locks?: LockManagerLike }).locks : undefined; + return locks && typeof locks.request === 'function' ? locks : null; +} + +export function tokenLockName(slot: number): string { + return `bulwark-token-refresh:${slot}`; +} + +export async function withTokenLock<T>( + slot: number, + work: (turn: TokenLockTurn) => Promise<T>, + options: { waitMs?: number; signal?: AbortSignal } = {}, +): Promise<T> { + const locks = lockManager(); + if (!locks) return work({ contended: false }); + + const name = tokenLockName(slot); + let started = false; + const run = (contended: boolean) => { + started = true; + return work({ contended }); + }; + + const gaveUp = new AbortController(); + const forwardAbort = () => gaveUp.abort(); + options.signal?.addEventListener('abort', forwardAbort); + let timer: ReturnType<typeof setTimeout> | undefined; + try { + const first = await locks.request<T | typeof NOT_HELD>(name, { ifAvailable: true }, (lock) => (lock ? run(false) : NOT_HELD)); + if (first !== NOT_HELD) return first as T; + timer = setTimeout(() => gaveUp.abort(), options.waitMs ?? TOKEN_LOCK_WAIT_MS); + return await locks.request<T>(name, { signal: gaveUp.signal }, () => { + clearTimeout(timer); + return run(true); + }); + } catch (error) { + // An error from the work goes to the caller, who decides what it means. So + // does a caller calling it off (a sign-out). Only the lock itself failing, + // or the wait running out, runs the work. + if (started || options.signal?.aborted) throw error; + return run(true); + } finally { + clearTimeout(timer); + options.signal?.removeEventListener('abort', forwardAbort); + } +} diff --git a/lib/compose-flush-registry.ts b/lib/compose-flush-registry.ts index d9ea445f8..b0215caab 100644 --- a/lib/compose-flush-registry.ts +++ b/lib/compose-flush-registry.ts @@ -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(); }; } diff --git a/lib/desktop/__tests__/browser-side.test.ts b/lib/desktop/__tests__/browser-side.test.ts new file mode 100644 index 000000000..6d7d8d23a --- /dev/null +++ b/lib/desktop/__tests__/browser-side.test.ts @@ -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); + } + }); +}); diff --git a/lib/desktop/__tests__/custom-upstream.test.ts b/lib/desktop/__tests__/custom-upstream.test.ts new file mode 100644 index 000000000..6e3bd20a7 --- /dev/null +++ b/lib/desktop/__tests__/custom-upstream.test.ts @@ -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('answers 503 when the provider cannot be reached, so the client waits it out', 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(503); + }); +}); diff --git a/lib/desktop/__tests__/login-routes.test.ts b/lib/desktop/__tests__/login-routes.test.ts new file mode 100644 index 000000000..57ef7b1bb --- /dev/null +++ b/lib/desktop/__tests__/login-routes.test.ts @@ -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({}); + }); +}); diff --git a/lib/desktop/__tests__/shell-contract.test.ts b/lib/desktop/__tests__/shell-contract.test.ts new file mode 100644 index 000000000..adcd717f7 --- /dev/null +++ b/lib/desktop/__tests__/shell-contract.test.ts @@ -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); + }); +}); diff --git a/lib/desktop/__tests__/trust-flag.test.ts b/lib/desktop/__tests__/trust-flag.test.ts new file mode 100644 index 000000000..c40bd5846 --- /dev/null +++ b/lib/desktop/__tests__/trust-flag.test.ts @@ -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'); + } + }); +}); diff --git a/lib/desktop/blocked-routes.ts b/lib/desktop/blocked-routes.ts new file mode 100644 index 000000000..093b38cf0 --- /dev/null +++ b/lib/desktop/blocked-routes.ts @@ -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); +} diff --git a/lib/desktop/boot.ts b/lib/desktop/boot.ts new file mode 100644 index 000000000..01f68c7a6 --- /dev/null +++ b/lib/desktop/boot.ts @@ -0,0 +1,8 @@ +/** + * Runs in the inline boot script, before the first paint. The preload bridge + * exists before any page script, so app/desktop.css holds from the first + * frame. Nothing happens in a browser, which has no bridge. + */ +export function desktopBootScript(): string { + return `if (window.bulwarkDesktop) document.documentElement.setAttribute('data-desktop', '');`; +} diff --git a/lib/desktop/bridge.ts b/lib/desktop/bridge.ts new file mode 100644 index 000000000..922a5c1b5 --- /dev/null +++ b/lib/desktop/bridge.ts @@ -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; +} diff --git a/lib/desktop/browser-sign-in.ts b/lib/desktop/browser-sign-in.ts new file mode 100644 index 000000000..a48437771 --- /dev/null +++ b/lib/desktop/browser-sign-in.ts @@ -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) }; + } +} diff --git a/lib/desktop/flag.ts b/lib/desktop/flag.ts new file mode 100644 index 000000000..de72502e1 --- /dev/null +++ b/lib/desktop/flag.ts @@ -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'; diff --git a/lib/desktop/jmap-route.ts b/lib/desktop/jmap-route.ts new file mode 100644 index 000000000..8eea5a83a --- /dev/null +++ b/lib/desktop/jmap-route.ts @@ -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)}`; +} diff --git a/lib/desktop/login-budget.ts b/lib/desktop/login-budget.ts new file mode 100644 index 000000000..a630a8153 --- /dev/null +++ b/lib/desktop/login-budget.ts @@ -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'; + } +} diff --git a/lib/desktop/trusted-servers.ts b/lib/desktop/trusted-servers.ts new file mode 100644 index 000000000..e2cf034b5 --- /dev/null +++ b/lib/desktop/trusted-servers.ts @@ -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; +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts new file mode 100644 index 000000000..e049b727b --- /dev/null +++ b/lib/desktop/types.ts @@ -0,0 +1,132 @@ +/** + * 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. + * + * 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. 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 { + interface Window { + bulwarkDesktop?: BulwarkDesktopBridge; + } +} diff --git a/lib/desktop/window-sync.ts b/lib/desktop/window-sync.ts new file mode 100644 index 000000000..c89514680 --- /dev/null +++ b/lib/desktop/window-sync.ts @@ -0,0 +1,42 @@ +/** + * Keeps what the windows of the app share up to date without a reload: theme, + * language and settings. Each is a persisted store, so a change in one window + * reaches the others as a `storage` event. Read state and drafts go through + * lib/popout/channel.ts, and each window keeps its own account (see + * local-data/desktop-offline-decisions.md). + * + * Only in pop-outs and the desktop app: tabs of a browser keep today's + * behaviour, where a change shows at the next load. + */ + +import { useLocaleStore } from '@/stores/locale-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { hasDesktopWindows } from '@/lib/popout/support'; +import { isPopoutWindow } from '@/lib/popout/window-role'; + +export function windowSyncWanted(): boolean { + return isPopoutWindow() || hasDesktopWindows(); +} + +/** Starts listening; returns the stop. Does nothing where windowSyncWanted says no. */ +export function startWindowSync(): () => void { + if (!windowSyncWanted()) return () => {}; + const onStorage = (event: StorageEvent) => { + switch (event.key) { + case 'theme-storage': + // Rehydrating sets the state; this puts it on the page. + void Promise.resolve(useThemeStore.persist.rehydrate()).then(() => useThemeStore.getState().initializeTheme()); + break; + case 'locale-storage': + // IntlProvider switches the catalog when the store changes. + void useLocaleStore.persist.rehydrate(); + break; + case 'settings-storage': + void useSettingsStore.persist.rehydrate(); + break; + } + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 1104eed91..b57de870c 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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"; @@ -1690,6 +1692,25 @@ export class JMAPClient implements IJMAPClient { return client; } + /** + * A client on a credential another window of this browser holds (a pop-out, + * lib/popout): the header as that window sends it, whatever kind it is. + */ + static withSharedAuth( + serverUrl: string, + username: string, + authHeader: string, + onTokenRefresh?: () => Promise<string | null>, + ): JMAPClient { + const client = new JMAPClient(serverUrl, username, ''); + client.authHeader = authHeader; + if (authHeader.startsWith('Bearer ')) { + client.authMode = 'bearer'; + client.onTokenRefresh = onTokenRefresh; + } + return client; + } + updateAccessToken(token: string): void { this.authHeader = `Bearer ${token}`; } @@ -1894,7 +1915,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 { @@ -1944,7 +1965,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 @@ -1987,7 +2008,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(); @@ -2144,10 +2165,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); } @@ -2180,6 +2206,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 +6542,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; } @@ -6992,6 +7026,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; diff --git a/lib/jmap/offline-hooks.ts b/lib/jmap/offline-hooks.ts index b908eafb7..fed004080 100644 --- a/lib/jmap/offline-hooks.ts +++ b/lib/jmap/offline-hooks.ts @@ -22,6 +22,8 @@ export interface OfflineClientRef { getServerUrl(): string; getUsername(): string; getAccountId(): string; + /** Answers from this device only: a local copy on screen at a start. */ + isOfflineOnly?(): boolean; } export interface OfflineHooks { @@ -86,6 +88,13 @@ export function isNetworkFailure(error: unknown): boolean { return error.name === 'TypeError' && /fetch|network|load failed|internet/i.test(error.message); } +/** The connection is fine and a proxy answered: the mail server behind it is what's gone. */ +export function isServerOutage(error: unknown): boolean { + if (!(error instanceof Error)) return false; + if (error.name === 'GatewayError') return true; + return error.name === 'SendNotStartedError' && (error as SendNotStartedError).cause instanceof Error && ((error as SendNotStartedError).cause as Error).name === 'GatewayError'; +} + /** Thrown for a request the device can't answer while the server can't be reached. */ export class OfflineUnavailableError extends Error { constructor(message = 'Not available offline') { diff --git a/lib/oauth/custom-upstream.ts b/lib/oauth/custom-upstream.ts new file mode 100644 index 000000000..c8230f36f --- /dev/null +++ b/lib/oauth/custom-upstream.ts @@ -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); +} diff --git a/lib/oauth/token-exchange.ts b/lib/oauth/token-exchange.ts index a60031595..6a8e2437e 100644 --- a/lib/oauth/token-exchange.ts +++ b/lib/oauth/token-exchange.ts @@ -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 diff --git a/lib/offline/__tests__/effective-scope-wiring.test.ts b/lib/offline/__tests__/effective-scope-wiring.test.ts index c454a8570..fa421b495 100644 --- a/lib/offline/__tests__/effective-scope-wiring.test.ts +++ b/lib/offline/__tests__/effective-scope-wiring.test.ts @@ -109,7 +109,7 @@ describe('the user picking what to keep', () => { await store.sync('acc'); chooseScope('acc', { days: 30 }); await vi.waitFor(() => expect(ids()).toEqual(['new'])); - expect(JSON.parse(localStorage.getItem('bulwark-offline-choices')!).acc.days).toBe(30); + expect(JSON.parse(localStorage.getItem('bulwark-offline-scope')!).acc.days).toBe(30); }); it('can\'t get past the cap', () => { @@ -150,23 +150,22 @@ describe('clearing an account', () => { expect(store.removed).toContain('acc'); expect(useOfflineStore.getState().enabled).toEqual(['acc']); expect(store.dbs.get('acc')).not.toBe(before); - expect(useOfflineStore.getState().health.acc?.opened).toBe(false); - // It isn't read as a browser clearing the store at the next open. - expect(notices.filter((n) => n.kind === 'evicted')).toEqual([]); + expect(useOfflineStore.getState().health.acc?.clearedAt).toBeGreaterThan(0); + expect(notices.filter((n) => n.kind === 'store-reset')).toEqual([]); }); }); describe('clearing in another tab', () => { it('opens the new store here, in case this tab owns it', async () => { writeEnabled(['acc']); - writeHealth('acc', { opened: true }); + writeHealth('acc', { clearedAt: 1 }); const stop = startOfflineMode(); const ensureOpen = vi.spyOn(store, 'ensureOpen'); ensureOpen.mockClear(); - localStorage.setItem(OFFLINE_HEALTH_KEY, JSON.stringify({ acc: { opened: false, pending: 0 } })); + localStorage.setItem(OFFLINE_HEALTH_KEY, JSON.stringify({ acc: { clearedAt: 2, pending: 0 } })); window.dispatchEvent(new StorageEvent('storage', { key: OFFLINE_HEALTH_KEY })); expect(ensureOpen).toHaveBeenCalledWith('acc'); - expect(useOfflineStore.getState().health.acc.opened).toBe(false); + expect(useOfflineStore.getState().health.acc.clearedAt).toBe(2); stop(); }); }); diff --git a/lib/offline/__tests__/migrate-legacy.test.ts b/lib/offline/__tests__/migrate-legacy.test.ts index 532f8ba4d..90ac665f9 100644 --- a/lib/offline/__tests__/migrate-legacy.test.ts +++ b/lib/offline/__tests__/migrate-legacy.test.ts @@ -6,7 +6,7 @@ import { forgetLegacyMove, LEGACY_DB_NAME, moveLegacyOutbox } from '../migrate-l import { readQueue } from '../queue'; import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; import { effectiveScope, platformDefault, userChoiceFor } from '../scope'; -import { OFFLINE_CHOICES_KEY, useOfflineStore, writeChoice } from '../state'; +import { OFFLINE_SCOPE_KEY, useOfflineStore, writeChoice } from '../state'; /** The old database, exactly as lib/offline/db.ts laid it out. */ const STORES = ['keys', 'emails', 'bodies', 'blobs', 'misc', 'ledger', 'queue', 'uploads']; @@ -87,6 +87,22 @@ afterEach(() => { }); describe('moving the old outbox into the store', () => { + it('gives a sender written "Name <address>" a Message-ID with a clean domain', async () => { + await seedOld(); + const db = await new Promise<IDBDatabase>((resolve) => { + const request = indexedDB.open(LEGACY_DB_NAME); + request.onsuccess = () => resolve(request.result); + }); + await put(db, 'queue', { + id: 20, account: 'acc', kind: 'send', createdAt: 20, status: 'waiting', + ...(await seal({ summary: { subject: 'Named', to: [] }, item: { kind: 'email', args: [[], 'Named', 'x', null, null, 'i', 'Alice <a@example.org>'] } })), + }); + db.close(); + await moveLegacyOutbox(); + const named = (await readQueue('acc')).outbox.find((entry) => entry.summary.subject === 'Named'); + expect(named?.messageId).toMatch(/@example\.org$/); + }); + it('moves everything that can\'t be fetched again, then deletes the old database', async () => { await seedOld(); await moveLegacyOutbox(); @@ -165,7 +181,7 @@ describe('moving the old outbox into the store', () => { const state = useOfflineStore.getState(); expect(state.enabled).toContain('acc'); expect(state.choices.acc).toMatchObject({ on: true, days: 30 }); - expect(JSON.parse(localStorage.getItem(OFFLINE_CHOICES_KEY)!).acc.days).toBe(30); + expect(JSON.parse(localStorage.getItem(OFFLINE_SCOPE_KEY)!).acc.days).toBe(30); expect(effectiveScope('acc', userChoiceFor('acc'), DEFAULT_ORGANISATION_POLICY.devices, platformDefault()).scope.days).toBe(30); }); diff --git a/lib/offline/__tests__/outbox.test.ts b/lib/offline/__tests__/outbox.test.ts index f89c609e4..f01524e44 100644 --- a/lib/offline/__tests__/outbox.test.ts +++ b/lib/offline/__tests__/outbox.test.ts @@ -264,6 +264,8 @@ describe('a send whose response got lost', () => { const entry = (await readQueue('acc')).outbox[0]; await store.area('acc', 'outbox.claim', { id: entry.id }); await store.area('acc', 'outbox.update', { id: entry.id, status: 'uncertain' }); + // The wait before a lookup has passed. + store.dbs.get('acc')!.run('UPDATE outbox SET last_attempt_at = ?', [Date.now() - 31_000]); return entry; } diff --git a/lib/offline/__tests__/review-fixes.test.ts b/lib/offline/__tests__/review-fixes.test.ts index c516f61e5..a5bbc6a0d 100644 --- a/lib/offline/__tests__/review-fixes.test.ts +++ b/lib/offline/__tests__/review-fixes.test.ts @@ -19,17 +19,25 @@ import { confirmLoss, enforcePolicy, offlineSessionFor, + offlineSupported, pluginsNotRunning, + refreshQueueState, + setOfflineNotifier, startOfflineMode, stricterNow, + unsentCount, wipeEverything, } from '../offline-mode'; -import { flushQueue, settleStaleSends, SENDING_STALE_MS } from '../flush'; -import { claimForSending, holdFile, queueSend, readQueue } from '../queue'; -import { forgetLegacyMove } from '../migrate-legacy'; +import { flushQueue, forgetOrphanCheck, LOOKUP_WAIT_MS, ORPHAN_FILE_AGE_MS, settleStaleSends, SENDING_STALE_MS } from '../flush'; +import { installMayPass } from '@/lib/store/adapters/wasm'; +import { isServerOutage, SendNotStartedError } from '@/lib/jmap/offline-hooks'; +import { claimForSending, holdFile, LOCAL_BLOB_PREFIX, queueSend, readQueue, updateOutboxEntry } from '../queue'; +import { forgetLegacyMove, strandedLegacyMessages } from '../migrate-legacy'; import { consumeLost } from '../lost'; +import { getOutboxHooks } from '../outbox-hooks'; import { shellFiles } from '../shell'; -import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, setServerDown, useOfflineStore, writeEnabled } from '../state'; +import { DEFAULT_SCOPE } from '@/lib/store/area'; +import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, OFFLINE_EVICTED_KEY, OFFLINE_SCOPE_KEY, offlineScopeFor, setServerDown, useOfflineStore, writeEnabled } from '../state'; function fakeClient(server = 'https://mail.example.org', overrides: Record<string, unknown> = {}) { return { @@ -56,6 +64,7 @@ function setOnline(online: boolean) { } let stop: () => void = () => {}; +const useRealClient = useAuthStore.getState().getClientForAccount; let store: InProcessStore; beforeEach(async () => { @@ -283,6 +292,385 @@ describe('11: starting from the device', () => { writeEnabled(['acc']); expect(await offlineSessionFor('acc')).toMatchObject({ primaryAccounts: { 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT } }); }); + + it('gives up on a policy fetch that never answers', async () => { + writeEnabled(['acc']); + usePolicyStore.setState({ loaded: false, fetching: false, source: 'none' }); + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))); + try { + const session = offlineSessionFor('acc'); + await vi.advanceTimersByTimeAsync(10_000); + expect(await session).toBeNull(); + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it('doesn\'t wait for the old database to finish moving', async () => { + writeEnabled(['acc']); + forgetLegacyMove(); + const legacy = indexedDB.open; + vi.spyOn(indexedDB, 'open').mockImplementation(((name: string, version?: number) => { + // A database that never opens. + if (name === 'bulwark-offline') return { result: null } as never; + return legacy.call(indexedDB, name, version); + }) as never); + vi.useFakeTimers(); + try { + const session = offlineSessionFor('acc'); + await vi.advanceTimersByTimeAsync(10_000); + expect(await session).toMatchObject({ primaryAccounts: { 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT } }); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } + }); + + it('doesn\'t offer a quick start before the store has its folders', async () => { + store = new InProcessStore(new FakeJmapMail()); + setStoreBackend(store); + writeEnabled(['acc']); + expect(await offlineSessionFor('acc', { withFolders: true })).toBeNull(); + await store.sync('acc'); + expect(await offlineSessionFor('acc', { withFolders: true })).toMatchObject({ primaryAccounts: { 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT } }); + }); +}); + +describe('what a sign-out would delete', () => { + beforeEach(() => writeEnabled(['acc', 'other'])); + + const addCalendarEdit = async (account: string) => { + await store.ensureOpen(account); + store.dbs.get(account)!.run("INSERT INTO calendar_pending (jmap, record_id, kind, patch, queued_at) VALUES ('j1', 'ev', 'update', '{}', 1)"); + }; + + it('counts the queue itself, not what the screen last saw', async () => { + await queueSend('acc', { subject: 'A', to: [] }, { kind: 'email', args: [] }); + await queueSend('other', { subject: 'B', to: [] }, { kind: 'email', args: [] }); + expect(useOfflineStore.getState().outbox).toEqual([]); + expect(await unsentCount('acc')).toEqual({ messages: 1, changes: 0 }); + expect(await unsentCount('all')).toEqual({ messages: 2, changes: 0 }); + }); + + it('counts changes and calendar edits, for the account asked about', async () => { + await addCalendarEdit('acc'); + await store.area('acc', 'outbox.queueSend', { summary: { subject: 'A', to: [] }, item: { kind: 'email', args: [] } }); + expect(await unsentCount('other')).toEqual({ messages: 0, changes: 0 }); + expect(await unsentCount('acc')).toEqual({ messages: 1, changes: 1 }); + }); + + it('counts an area that isn\'t here as nothing', async () => { + store.failArea = (name) => name === 'contacts.pending'; + await addCalendarEdit('acc'); + expect((await unsentCount('acc')).changes).toBe(1); + }); + + it('answers with the last known count when the store hangs', async () => { + useOfflineStore.setState({ outbox: [{ id: 1, account: 'acc', createdAt: 1, subject: 'A', to: [], status: 'waiting' }] as never, pendingChanges: 2 }); + const hung: Array<(error: Error) => void> = []; + vi.spyOn(store, 'area').mockImplementation(() => new Promise((_resolve, reject) => { hung.push(reject); })); + expect(await unsentCount('acc', 10)).toEqual({ messages: 1, changes: 2 }); + for (const reject of hung) reject(new Error('Gone')); + }); + + it('doesn\'t count another signed-out account\'s waiting mail', async () => { + await queueSend('other', { subject: 'B', to: [] }, { kind: 'email', args: [] }); + localStorage.setItem(OFFLINE_EVICTED_KEY, JSON.stringify({ other: Date.now() })); + expect(await unsentCount('all')).toEqual({ messages: 0, changes: 0 }); + }); +}); + +describe('a store that won\'t open', () => { + beforeEach(() => writeEnabled(['acc', 'other'])); + + it('holds up no other account, and keeps what it last showed', async () => { + await queueSend('acc', { subject: 'A', to: [] }, { kind: 'email', args: [] }); + await queueSend('other', { subject: 'B', to: [] }, { kind: 'email', args: [] }); + await refreshQueueState(); + const area = store.area.bind(store); + vi.spyOn(store, 'area').mockImplementation(((account: string, name: string, params: unknown) => (account === 'other' ? Promise.reject(new Error('The local store could not start')) : area(account, name, params))) as never); + const notify = vi.fn(); + setOfflineNotifier(notify); + await refreshQueueState(); + expect(useOfflineStore.getState().outbox.map((row) => row.subject).sort()).toEqual(['A', 'B']); + expect(notify).toHaveBeenCalledWith({ kind: 'store-unavailable' }); + // Not again right away. + notify.mockClear(); + await refreshQueueState(); + expect(notify).not.toHaveBeenCalled(); + }); + + it('doesn\'t call an unreadable queue empty', async () => { + await queueSend('acc', { subject: 'A', to: [] }, { kind: 'email', args: [] }); + await refreshQueueState(); + vi.spyOn(store, 'area').mockRejectedValue(new Error('The local store could not start')); + await refreshQueueState(); + expect(useOfflineStore.getState().outbox.map((row) => row.subject)).toEqual(['A']); + await expect(readQueue()).rejects.toThrow('could not start'); + }); +}); + +describe('a send the server couldn\'t take right now', () => { + const statusAfter = async (error: Error) => { + writeEnabled(['acc']); + await queueSend('acc', { subject: 'Hello', to: [] }, { kind: 'email', args: [] }); + const notify = vi.fn(); + const retryLater = vi.fn(); + const sendEmail = vi.fn(async () => { throw error; }); + await flushQueue('acc', fakeClient('https://mail.example.org', { sendEmail }) as unknown as JMAPClient, { notify, allowed: () => true, retryLater }); + const row = (await readQueue('acc')).outbox[0]; + return { row, notify, retryLater }; + }; + + it.each([ + ['a rate limit', Object.assign(new Error('Rate limited by server'), { name: 'RateLimitError' })], + ['a login that needs renewing', new Error('Request failed: 401 - ')], + ['a store that went away', Object.assign(new Error('gone'), { name: 'StoreOwnerGoneError' })], + ['a full disk', Object.assign(new Error('full'), { name: 'QuotaExceededError' })], + ])('goes back to waiting, on a timer, after %s', async (_name, error) => { + const { row, notify, retryLater } = await statusAfter(error); + expect(row.status).toBe('waiting'); + expect(retryLater).toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + }); + + it.each(['Request failed: 500 - oops', 'Request failed: 501 - nope'])('is looked up after %s, which may have got through', async (message) => { + const { row, notify, retryLater } = await statusAfter(new Error(message)); + expect(row.status).toBe('uncertain'); + expect(retryLater).toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + }); + + it('fails, for the user to see, on a refusal', async () => { + const { row, notify } = await statusAfter(new Error('Request failed: 400 - {"type":"urn:ietf:params:jmap:error:notRequest"}')); + expect(row.status).toBe('failed'); + expect(notify).toHaveBeenCalledWith(expect.objectContaining({ kind: 'send-failed' })); + }); + + it('keeps the copy on the server through a wait', async () => { + writeEnabled(['acc']); + const id = await queueSend('acc', { subject: 'Hello', to: [] }, { kind: 'email', args: [] }); + const [entry] = (await readQueue('acc')).outbox; + await updateOutboxEntry(entry, { status: 'waiting', copyId: 'copy-1' }); + const sendEmail = vi.fn(async () => { throw new Error('Request failed: 429 - '); }); + await flushQueue('acc', fakeClient('https://mail.example.org', { sendEmail }) as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + const row = (await readQueue('acc')).outbox.find((candidate) => candidate.id === id); + expect(row).toMatchObject({ status: 'waiting', copyId: 'copy-1' }); + }); + + it('doesn\'t hand an online send a stand-in for a file the upload held again', async () => { + writeEnabled(['acc']); + stop = startOfflineMode(); + const { blobId } = await holdFile('acc', new File(['x'], 'x.txt', { type: 'text/plain' }), 'x.txt'); + const client = fakeClient('https://mail.example.org', { uploadBlob: vi.fn(async () => ({ blobId: `${LOCAL_BLOB_PREFIX}stand-in`, size: 1, type: 'text/plain' })) }); + useAuthStore.setState({ client: client as never, activeAccountId: 'acc', getClientForAccount: () => client } as never); + try { + await expect(getOutboxHooks()!.resolveHeldAttachments(client as never, [{ blobId }])).rejects.toMatchObject({ name: 'SendNotStartedError' }); + } finally { + useAuthStore.setState({ getClientForAccount: useRealClient, client: null }); + } + }); + + it('doesn\'t send a stand-in for a file the upload held again', async () => { + writeEnabled(['acc']); + const { blobId } = await holdFile('acc', new File(['x'], 'x.txt', { type: 'text/plain' }), 'x.txt'); + await queueSend('acc', { subject: 'Hello', to: [] }, { kind: 'email', args: [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, [{ blobId, name: 'x.txt', type: 'text/plain', size: 1 }]] }); + const sendEmail = vi.fn(async () => ({ scheduled: false, emailId: 'e', emailSubmissionId: 's' })); + const uploadBlob = vi.fn(async () => ({ blobId: `${LOCAL_BLOB_PREFIX}stand-in`, size: 1, type: 'text/plain' })); + await flushQueue('acc', fakeClient('https://mail.example.org', { sendEmail, uploadBlob }) as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(sendEmail).not.toHaveBeenCalled(); + expect((await readQueue('acc')).outbox[0].status).toBe('waiting'); + }); +}); + +describe('waiting mail after a normal start', () => { + const realFetchPolicy = usePolicyStore.getState().fetchPolicy; + + beforeEach(() => { + vi.useFakeTimers(); + // rawPolicy: what the policy is worked out from again when the subject changes. + usePolicyStore.setState({ fetchPolicy: vi.fn(async () => {}), source: 'server', rawPolicy: usePolicyStore.getState().policy } as never); + useOfflineStore.setState({ downServers: [] }); + writeEnabled(['acc']); + }); + + afterEach(() => { + vi.useRealTimers(); + usePolicyStore.setState({ fetchPolicy: realFetchPolicy }); + useOfflineStore.setState({ downServers: [] }); + }); + + it('goes when the client becomes real after the flush at the start', async () => { + await queueSend('acc', { subject: 'Hello', to: [] }, { kind: 'email', args: [] }); + stop = startOfflineMode(); + // The flush at the start finds no client yet. + await vi.advanceTimersByTimeAsync(5_000); + const client = fakeClient(); + useAuthStore.setState({ getClientForAccount: () => client, client: client as never } as never); + try { + await vi.advanceTimersByTimeAsync(1_500); + expect(client.sendEmail).toHaveBeenCalledTimes(1); + } finally { + useAuthStore.setState({ getClientForAccount: useRealClient, client: null }); + } + }); + + it('comes back to a send a closed tab left behind', async () => { + const id = await queueSend('acc', { subject: 'Hello', to: [] }, { kind: 'email', args: [] }); + await claimForSending('acc', id); + stop = startOfflineMode(); + await vi.advanceTimersByTimeAsync(5_000); + expect((await readQueue('acc')).outbox[0].status).toBe('sending'); + await vi.advanceTimersByTimeAsync(SENDING_STALE_MS); + expect((await readQueue('acc')).outbox[0].status).toBe('uncertain'); + }); + + it('asks a server that was down, on an interval, and flushes when it answers', async () => { + const fetcher = vi.fn(async (_url: string) => new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetcher); + try { + stop = startOfflineMode(); + await vi.advanceTimersByTimeAsync(5_000); + setServerDown('https://mail.example.org', true); + await vi.advanceTimersByTimeAsync(30_000); + expect(fetcher.mock.calls.map(([url]) => String(url))).toContain('https://mail.example.org/.well-known/jmap'); + expect(isOfflineNow('https://mail.example.org')).toBe(false); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + +describe('old files', () => { + it('go after a flush, once a day, not a file that\'s still recent', async () => { + forgetOrphanCheck(); + writeEnabled(['acc']); + const { blobId } = await holdFile('acc', new File(['x'], 'x.txt', { type: 'text/plain' }), 'x.txt'); + const client = fakeClient() as unknown as JMAPClient; + const flush = () => flushQueue('acc', client, { notify: vi.fn(), allowed: () => true }); + await flush(); + expect(await store.area('acc', 'outbox.heldFile', { blobId })).not.toBeNull(); + + const area = vi.spyOn(store, 'area'); + await flush(); + expect(area.mock.calls.some(([, name]) => name === 'outbox.dropOrphans')).toBe(false); + + const later = Date.now() + ORPHAN_FILE_AGE_MS + 1000; + const clock = vi.spyOn(Date, 'now').mockReturnValue(later); + try { + await flush(); + expect(area.mock.calls.some(([, name]) => name === 'outbox.dropOrphans')).toBe(true); + expect(await store.area('acc', 'outbox.heldFile', { blobId })).toBeNull(); + } finally { + clock.mockRestore(); + area.mockRestore(); + } + }); +}); + +describe('a browser that can\'t run the store', () => { + it('says so when the old database holds unsent mail', async () => { + await new Promise<void>((resolve) => { + const request = indexedDB.open('bulwark-offline', 1); + request.onupgradeneeded = () => { request.result.createObjectStore('queue', { keyPath: 'id' }); }; + request.onsuccess = () => { + const put = request.result.transaction('queue', 'readwrite').objectStore('queue'); + put.put({ id: 1, kind: 'patch' }); + put.put({ id: 2, kind: 'send' }); + put.transaction.oncomplete = () => { request.result.close(); resolve(); }; + }; + }); + expect(await strandedLegacyMessages()).toBe(1); + store.isSupported = false; + const notify = vi.fn(); + setOfflineNotifier(notify); + stop = startOfflineMode(); + await vi.waitFor(() => expect(notify).toHaveBeenCalledWith({ kind: 'legacy-stranded' })); + store.isSupported = true; + await new Promise<void>((resolve) => { + const remove = indexedDB.deleteDatabase('bulwark-offline'); + remove.onsuccess = () => resolve(); + }); + expect(await strandedLegacyMessages()).toBe(0); + }); + + it('turns the switch off in a window that refuses the private file system, and wipes nothing', async () => { + writeEnabled(['acc']); + Object.defineProperty(window.navigator, 'storage', { configurable: true, value: { getDirectory: async () => { throw new DOMException('The operation is insecure.', 'SecurityError'); } } }); + try { + stop = startOfflineMode(); + await vi.waitFor(() => expect(useOfflineStore.getState().storageBlocked).toBe(true)); + expect(offlineSupported()).toBe(false); + enforcePolicy(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(useOfflineStore.getState().enabled).toEqual(['acc']); + } finally { + delete (window.navigator as { storage?: unknown }).storage; + useOfflineStore.setState({ storageBlocked: false }); + } + }); + + it('doesn\'t retry the install where the browser refuses it', () => { + expect(installMayPass(new DOMException('The operation is insecure.', 'SecurityError'))).toBe(false); + expect(installMayPass(new DOMException('Handle is busy', 'NoModificationAllowedError'))).toBe(true); + }); +}); + +describe('the wording of an outage', () => { + it('tells a proxy with no server behind it from a missing connection', () => { + const gateway = Object.assign(new Error('Request failed: 502'), { name: 'GatewayError' }); + expect(isServerOutage(gateway)).toBe(true); + expect(isServerOutage(new SendNotStartedError(gateway))).toBe(true); + expect(isServerOutage(new SendNotStartedError(new TypeError('Failed to fetch')))).toBe(false); + expect(isServerOutage(new TypeError('Failed to fetch'))).toBe(false); + }); +}); + +describe('looking up a send that may have gone', () => { + it('waits until about 30 seconds after the last try', async () => { + writeEnabled(['acc']); + const id = await queueSend('acc', { subject: 'Hello', to: [] }, { kind: 'email', args: [] }, undefined, 'fixed@example.org'); + await claimForSending('acc', id); + await updateOutboxEntry({ account: 'acc', id }, { status: 'uncertain' }); + const requestNetworkOnly = vi.fn(async () => { throw new TypeError('Failed to fetch'); }); + const client = fakeClient('https://mail.example.org', { requestNetworkOnly, resolveSendAccountId: async () => 'j1', submissionAccountFor: () => 'j1' }) as unknown as JMAPClient; + const retryLater = vi.fn(); + await flushQueue('acc', client, { notify: vi.fn(), allowed: () => true, retryLater }); + expect(requestNetworkOnly).not.toHaveBeenCalled(); + expect(retryLater).toHaveBeenCalled(); + const later = Date.now() + LOOKUP_WAIT_MS + 1000; + const clock = vi.spyOn(Date, 'now').mockReturnValue(later); + try { + await flushQueue('acc', client, { notify: vi.fn(), allowed: () => true, retryLater }); + expect(requestNetworkOnly).toHaveBeenCalled(); + } finally { + clock.mockRestore(); + } + }); +}); + +describe('what the store is told to keep', () => { + it('follows what the account chose, on top of the defaults', () => { + expect(offlineScopeFor('acc')).toEqual(DEFAULT_SCOPE); + localStorage.setItem(OFFLINE_SCOPE_KEY, JSON.stringify({ acc: { attachments: false, days: 7 } })); + expect(offlineScopeFor('acc')).toEqual({ ...DEFAULT_SCOPE, attachments: false, days: 7 }); + expect(offlineScopeFor('other')).toEqual(DEFAULT_SCOPE); + }); +}); + +describe('a send on the local copy', () => { + it('queues, before the swap too, while the probes say the server is up', () => { + writeEnabled(['acc']); + stop = startOfflineMode(); + useAuthStore.setState({ client: fakeClient('https://mail.example.org', { isOfflineOnly: () => true }) as never }); + const client = useAuthStore.getState().client as never; + expect(isOfflineNow('https://mail.example.org')).toBe(false); + expect(getOutboxHooks()?.shouldQueue(client)).toBe(true); + useAuthStore.setState({ client: fakeClient() as never }); + expect(getOutboxHooks()?.shouldQueue(useAuthStore.getState().client as never)).toBe(false); + }); }); describe('12: one server down doesn’t hold another’s mail', () => { diff --git a/lib/offline/__tests__/scope.test.ts b/lib/offline/__tests__/scope.test.ts index 2531d97d4..0bc04950c 100644 --- a/lib/offline/__tests__/scope.test.ts +++ b/lib/offline/__tests__/scope.test.ts @@ -11,7 +11,7 @@ import { userChoiceFor, windowOptions, } from '../scope'; -import { useOfflineStore } from '../state'; +import { offlineScopeFor, useOfflineStore } from '../state'; const NO_LIMITS = DEFAULT_ORGANISATION_POLICY.devices; const MB = 1024 * 1024; @@ -25,7 +25,7 @@ describe('effectiveScope', () => { }); it('the user over the default', () => { - const effective = effectiveScope('a', { on: true, days: 30, attachments: false, maxAttachmentMb: 5 }, NO_LIMITS, BROWSER); + const effective = effectiveScope('a', { on: true, days: 30, attachments: false, maxAttachmentBytes: 5 * MB }, NO_LIMITS, BROWSER); expect(effective.enabled).toBe(true); expect(effective.scope).toEqual({ ...DEFAULT_SCOPE, days: 30, attachments: false, maxAttachmentBytes: 5 * MB }); expect(effectiveScope('a', { on: false }, NO_LIMITS, DESKTOP).enabled).toBe(false); @@ -41,11 +41,11 @@ describe('effectiveScope', () => { it('a cap lowers the choice and the default, also "everything", and never raises', () => { const policy = { ...NO_LIMITS, offlineMaxDays: 60, offlineMaxAttachmentMb: 10, offlineMaxTotalMb: 100 }; - const capped = effectiveScope('a', { days: null, maxAttachmentMb: 500 }, policy, DESKTOP); + const capped = effectiveScope('a', { days: null, maxAttachmentBytes: 500 * MB }, policy, DESKTOP); expect(capped.scope).toEqual({ days: 60, attachments: true, maxAttachmentBytes: 10 * MB, maxTotalBytes: 100 * MB }); expect(capped.locked).toMatchObject({ days: true, maxAttachment: true, maxTotal: true, attachments: false }); expect(effectiveScope('a', null, { ...policy, offlineMaxDays: 30 }, DESKTOP).scope.days).toBe(30); - const under = effectiveScope('a', { days: 30, maxAttachmentMb: 5 }, policy, BROWSER); + const under = effectiveScope('a', { days: 30, maxAttachmentBytes: 5 * MB }, policy, BROWSER); expect(under.scope.days).toBe(30); expect(under.scope.maxAttachmentBytes).toBe(5 * MB); }); @@ -69,6 +69,53 @@ describe('the platform default', () => { }); }); +describe('the OfflineEnabled policy of the desktop app', () => { + const withPolicy = (OfflineEnabled: unknown) => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, policy: { get: () => ({ OfflineEnabled }) } }; + }; + afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; }); + + it('recommended off changes the default, and a user can still turn it on', () => { + withPolicy({ value: false, locked: false, source: 'machine' }); + expect(offlineDefaultOn()).toBe(false); + expect(effectiveScope('a', null, NO_LIMITS, platformDefault()).enabled).toBe(false); + expect(effectiveScope('a', { on: true }, NO_LIMITS, platformDefault()).enabled).toBe(true); + }); + + it('the shipped default is no recommendation', () => { + withPolicy({ value: false, locked: false, source: 'default' }); + expect(offlineDefaultOn()).toBe(true); + }); + + it('forced beats what the user chose, either way', () => { + withPolicy({ value: true, locked: true, source: 'machine' }); + expect(effectiveScope('a', { on: false }, NO_LIMITS, platformDefault())).toMatchObject({ enabled: true, locked: { forced: true } }); + withPolicy({ value: false, locked: true, source: 'machine' }); + expect(effectiveScope('a', { on: true }, NO_LIMITS, platformDefault()).enabled).toBe(false); + }); + + it('the organisation still beats it', () => { + withPolicy({ value: true, locked: true, source: 'machine' }); + expect(effectiveScope('a', null, { ...NO_LIMITS, noOffline: true }, platformDefault()).enabled).toBe(false); + }); + + it('a shell without the member, or a member that throws, is the plain default', () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 }; + expect(offlineDefaultOn()).toBe(true); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, policy: { get: () => { throw new Error('gone'); } } }; + expect(platformDefault()).toMatchObject({ on: true, forced: null }); + }); +}); + +describe('the seam for what a user chose', () => { + it('reads bulwark-offline-scope over the defaults, without the switch', () => { + localStorage.setItem('bulwark-offline-scope', JSON.stringify({ acc: { on: true, days: 30 } })); + expect(offlineScopeFor('acc')).toEqual({ ...DEFAULT_SCOPE, days: 30 }); + expect(offlineScopeFor('other')).toEqual(DEFAULT_SCOPE); + localStorage.removeItem('bulwark-offline-scope'); + }); +}); + describe('what the user chose', () => { beforeEach(() => useOfflineStore.setState({ enabled: [], choices: {} })); diff --git a/lib/offline/__tests__/storage-health.test.ts b/lib/offline/__tests__/storage-health.test.ts index 86a85ae61..49400a0f1 100644 --- a/lib/offline/__tests__/storage-health.test.ts +++ b/lib/offline/__tests__/storage-health.test.ts @@ -15,8 +15,8 @@ import { wipeEverything, type OfflineNotice, } from '../offline-mode'; -import { readPersistence, requestPersistence, safariMayDelete, wasEvicted } from '../storage-health'; -import { OFFLINE_CHOICES_KEY, OFFLINE_HEALTH_KEY, useOfflineStore, writeChoice, writeEnabled, writeHealth } from '../state'; +import { readPersistence, requestPersistence, safariMayDelete } from '../storage-health'; +import { markEvicted, OFFLINE_HEALTH_KEY, OFFLINE_SCOPE_KEY, useOfflineStore, writeChoice, writeEnabled, writeHealth } from '../state'; /** Everything on one page: a BroadcastChannel that also reaches other objects here, as in a browser. */ class FakeChannel { @@ -37,8 +37,8 @@ class FakeChannel { class FakeWorker { static all: FakeWorker[] = []; - /** What the worker found on OPFS. */ - static fileExisted = true; + /** What the worker found on OPFS: no database under this name. */ + static created = false; onmessage: ((event: MessageEvent) => void) | null = null; posted: Array<Record<string, unknown>> = []; constructor() { @@ -46,8 +46,8 @@ class FakeWorker { } postMessage(message: Record<string, unknown>): void { this.posted.push(message); - if (message.kind === 'open') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: { fileExisted: FakeWorker.fileExisted } } } as MessageEvent)); - if (message.kind === 'sweep-files') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: 0 } } as MessageEvent)); + if (message.kind === 'open') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: { created: FakeWorker.created } } } as MessageEvent)); + if (message.kind === 'prune-files') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id } } as MessageEvent)); } terminate(): void {} } @@ -90,17 +90,17 @@ beforeEach(() => { deleteKeyDatabase(); FakeChannel.open.clear(); FakeWorker.all = []; - FakeWorker.fileExisted = true; + FakeWorker.created = false; rpcs.length = 0; notices.length = 0; vi.stubGlobal('BroadcastChannel', FakeChannel); vi.stubGlobal('Worker', FakeWorker); vi.stubGlobal('FileSystemFileHandle', class {}); setStorage({ getDirectory: async () => ({ removeEntry: async () => {} }), persist: async () => true, persisted: async () => true }); - Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { request: async (_name: string, fn: () => unknown) => fn() } }); + Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { request: async (_name: string, ...rest: unknown[]) => (rest.at(-1) as () => unknown)() } }); useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usage: {}, usageBytes: 0, choices: {}, health: {}, - storageFull: {}, progress: {}, persist: 'unknown', syncing: [], lastSyncedAt: {}, browserOffline: false, downServers: [], startedOffline: false, + storeLost: [], storageFull: {}, progress: {}, persist: 'unknown', syncing: [], lastSyncedAt: {}, browserOffline: false, downServers: [], startedOffline: false, }); usePolicyStore.setState({ loaded: true, source: 'server', subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never); setStoreBackend(null); @@ -131,57 +131,63 @@ async function openAsOwner(account: string): Promise<FakeWorker> { } describe('a store the browser cleared', () => { + const hadStore = (account: string) => localStorage.setItem('bulwark-store-opened', JSON.stringify([account])); + it('a key with no file: warns, with how many waiting items went', async () => { await openAccountKey('acc'); - writeHealth('acc', { opened: true, pending: 3 }); - FakeWorker.fileExisted = false; + hadStore('acc'); + writeHealth('acc', { pending: 3 }); + FakeWorker.created = true; await openAsOwner('acc'); - await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'evicted', lost: 3 })); - expect(useOfflineStore.getState().health.acc.evicted?.lost).toBe(3); - expect(useOfflineStore.getState().health.acc.pending).toBe(0); + await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'store-reset', lost: 3 })); + const state = useOfflineStore.getState(); + expect(state.storeLost).toEqual(['acc']); + expect(state.health.acc.reset?.lost).toBe(3); + expect(state.health.acc.pending).toBe(0); }); it('no key where one was made: warns too', async () => { - writeHealth('acc', { opened: true, pending: 0 }); - FakeWorker.fileExisted = false; + hadStore('acc'); await openAsOwner('acc'); - await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'evicted', lost: 0 })); + await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'store-reset', lost: 0 })); }); it('a first open is not an eviction', async () => { - FakeWorker.fileExisted = false; - await openAsOwner('acc'); - await vi.waitFor(() => expect(useOfflineStore.getState().health.acc?.opened).toBe(true)); - expect(notices.filter((n) => n.kind === 'evicted')).toEqual([]); + FakeWorker.created = true; + const worker = await openAsOwner('acc'); + await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'open')).toBe(true)); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(notices.filter((n) => n.kind === 'store-reset')).toEqual([]); + expect(useOfflineStore.getState().health.acc?.reset).toBeUndefined(); }); it('a normal start is not one either', async () => { await openAccountKey('acc'); - writeHealth('acc', { opened: true, pending: 3 }); + hadStore('acc'); + writeHealth('acc', { pending: 3 }); await openAsOwner('acc'); - await vi.waitFor(() => expect(FakeWorker.all[0].posted.some((m) => m.kind === 'open')).toBe(true)); await new Promise((resolve) => setTimeout(resolve, 5)); expect(notices).toEqual([]); + expect(useOfflineStore.getState().health.acc.pending).toBe(3); }); - it('files with no key are swept once this tab owns the store', async () => { - const { record } = await openAccountKey('acc'); - const worker = await openAsOwner('acc'); - await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'sweep-files')).toBe(true)); - expect((worker.posted.find((m) => m.kind === 'sweep-files') as { known: string[] }).known).toEqual([record.file]); - }); - - it('wasEvicted', () => { - expect(wasEvicted({ opened: true, hadKey: true, fileExisted: false })).toBe(true); - expect(wasEvicted({ opened: false, hadKey: true, fileExisted: false })).toBe(true); - expect(wasEvicted({ opened: true, hadKey: false, fileExisted: false })).toBe(true); - expect(wasEvicted({ opened: false, hadKey: false, fileExisted: false })).toBe(false); - expect(wasEvicted({ opened: true, hadKey: true, fileExisted: true })).toBe(false); + it('tells nobody who wasn\'t signed in to an account dropped by the app', async () => { + await openAccountKey('acc'); + hadStore('acc'); + writeHealth('acc', { pending: 3 }); + useOfflineStore.setState({ enabled: ['acc'] }); + markEvicted('acc'); + useOfflineStore.setState({ enabled: [] }); + FakeWorker.created = true; + await openAsOwner('acc'); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(notices).toEqual([]); + expect(useOfflineStore.getState().health.acc.reset).toBeUndefined(); }); it('keeps only numbers about it outside the store', () => { - writeHealth('acc', { opened: true, pending: 2, evicted: { at: 5, lost: 2 }, lastSync: 9 }); - expect(JSON.parse(localStorage.getItem(OFFLINE_HEALTH_KEY)!)).toEqual({ acc: { opened: true, pending: 2, evicted: { at: 5, lost: 2 }, lastSync: 9 } }); + writeHealth('acc', { pending: 2, reset: { at: 5, lost: 2 }, lastSync: 9 }); + expect(JSON.parse(localStorage.getItem(OFFLINE_HEALTH_KEY)!)).toEqual({ acc: { pending: 2, reset: { at: 5, lost: 2 }, lastSync: 9 } }); }); }); @@ -264,7 +270,7 @@ describe('removing an account', () => { writeEnabled(['gone', 'stays']); writeChoice('gone', { days: 30 }); writeChoice('stays', { days: 90 }); - writeHealth('gone', { opened: true, pending: 1 }); + writeHealth('gone', { pending: 1 }); await openAsOwner('stays'); await wipeAccount('gone'); @@ -274,7 +280,7 @@ describe('removing an account', () => { expect(rpcs).toContainEqual({ method: 'removeAccount', params: { account: 'gone', file: record.file } }); expect(useOfflineStore.getState().choices).toEqual({ stays: { days: 90 } }); expect(useOfflineStore.getState().health.gone).toBeUndefined(); - expect(JSON.parse(localStorage.getItem(OFFLINE_CHOICES_KEY)!)).toEqual({ stays: { days: 90 } }); + expect(JSON.parse(localStorage.getItem(OFFLINE_SCOPE_KEY)!)).toEqual({ stays: { days: 90 } }); }); it('with the last account, deletes the pool and every key', async () => { @@ -288,7 +294,7 @@ describe('removing an account', () => { expect(removeEntry).toHaveBeenCalledWith('.bulwark-store', { recursive: true }); await vi.waitFor(async () => expect(await getAccountRecord('acc')).toBeNull()); - expect(localStorage.getItem(OFFLINE_CHOICES_KEY)).toBeNull(); + expect(localStorage.getItem(OFFLINE_SCOPE_KEY)).toBeNull(); expect(localStorage.getItem(OFFLINE_HEALTH_KEY)).toBeNull(); }); @@ -313,13 +319,13 @@ describe('a full sign-out', () => { await openAccountKey('acc'); writeEnabled(['acc']); writeChoice('acc', { days: 30 }); - writeHealth('acc', { opened: true, pending: 2 }); + writeHealth('acc', { pending: 2 }); purgeSignedOutData(); await vi.waitFor(() => expect(removeEntry).toHaveBeenCalledWith('.bulwark-store', { recursive: true })); await vi.waitFor(async () => expect(await getAccountRecord('acc')).toBeNull()); - for (const key of ['bulwark-offline-accounts', OFFLINE_CHOICES_KEY, OFFLINE_HEALTH_KEY]) expect(localStorage.getItem(key)).toBeNull(); + for (const key of ['bulwark-offline-accounts', OFFLINE_SCOPE_KEY, OFFLINE_HEALTH_KEY]) expect(localStorage.getItem(key)).toBeNull(); expect(useOfflineStore.getState().choices).toEqual({}); expect(useOfflineStore.getState().health).toEqual({}); }); diff --git a/lib/offline/credentials-rejected.ts b/lib/offline/credentials-rejected.ts index 9511494f3..a42282998 100644 --- a/lib/offline/credentials-rejected.ts +++ b/lib/offline/credentials-rejected.ts @@ -1,8 +1,9 @@ /** - * The server refused this account's refresh token for good. Nothing wipes on - * this yet: a refused account is dropped, and its local data stays for the next - * sign-in. HOOK: the wipe work (lib/store/wipe) registers here to act on a - * permanent rejection. Free of the stores, so the auth store can call it. + * The server really refused this login (`invalid_grant`), which the auth store + * follows with a sign-out that wipes the account's local data. An outage or any + * other refusal doesn't come here, and keeps the data. HOOK: lib/store/wipe + * registers here for what else a permanent rejection should do. Free of the + * stores, so the auth store can call it. */ type Listener = (accountId: string) => void; diff --git a/lib/offline/flush.ts b/lib/offline/flush.ts index 021c39348..3aa05b411 100644 --- a/lib/offline/flush.ts +++ b/lib/offline/flush.ts @@ -11,9 +11,10 @@ * - A message is sent as it was finished at Send (queue.ts); files attached * offline are uploaded first. A hold for undo-send that has passed is * dropped, so the message goes at once. - * - Only a failure before the submission request (SendNotStartedError, a - * file upload on a dead network) puts a message back to waiting. A send - * whose connection broke after that is marked uncertain. A refusal by the + * - A failure before the submission request (SendNotStartedError, a file + * upload on a dead network), a rate limit, a login that needs renewing and + * trouble with this device's store put a message back to waiting. A send whose + * connection broke, or that got a 5xx, is marked uncertain. A refusal by the * server is marked failed, with the reason. * - An uncertain message is looked up when the server is back, by the * Message-ID fixed when it was queued (lib/store/outbox/dedupe.ts): it's @@ -29,6 +30,7 @@ import type { JMAPClient } from '@/lib/jmap/client'; import { isNetworkFailure } from '@/lib/jmap/offline-hooks'; import type { SendEmailResult } from '@/lib/jmap/types'; import { getStore } from '@/lib/store/client/api'; +import { isStorageFailure } from '@/lib/store/storage'; import type { MethodCall } from '@/lib/store/engine/transport'; import { decideRetry, type LookupIo, type RetryDecision } from '@/lib/store/outbox/dedupe'; import { flushPatches, type PatchIo, type PatchOutcome, type PendingPatch } from '@/lib/store/outbox/flush-patches'; @@ -42,7 +44,7 @@ import { type OutboxEntry, type OutboxError, } from './queue'; -import { useOfflineStore } from './state'; +import { visibleAccounts } from './state'; import type { OfflineNotice } from './offline-mode'; export interface FlushDeps { @@ -56,6 +58,9 @@ export interface FlushDeps { retryLater?: () => void; } +/** A message that just went may not show in a lookup yet, which would send it twice. */ +export const LOOKUP_WAIT_MS = 30_000; + /** A send is taken for at most this long before a closed tab's send counts as uncertain. */ export const SENDING_STALE_MS = 60_000; @@ -72,6 +77,23 @@ class OutboxPrepError extends Error { } } +/** + * Not the server's word about this message: it's rate limiting, the login needs + * renewing, or this device's store is in the way. The next try goes as usual. + */ +function waitsForNextTry(error: unknown): boolean { + if (!(error instanceof Error)) return false; + if (error.name === 'RateLimitError' || error.name === 'StoreOwnerGoneError' || error.name === 'StoreKeyError') return true; + if (isStorageFailure(error)) return true; + return /^Request failed: (401|408|429)\b/.test(error.message); +} + +/** A server error can come after the message went through: looked up before it goes again. */ +function mayHaveArrived(error: unknown): boolean { + if (isNetworkFailure(error)) return true; + return error instanceof Error && (error.name === 'SendUnconfirmedError' || /^Request failed: 5\d\d\b/.test(error.message)); +} + function notStartedError(message: string): Error { return Object.assign(new Error(message), { name: 'SendNotStartedError' }); } @@ -89,12 +111,16 @@ function notStarted(error: unknown): boolean { async function uploadHeld(account: string, client: JMAPClient, blobId: string): Promise<string> { const file = await heldFile(account, blobId); if (!file) throw new OutboxPrepError('file_missing'); + let uploaded: string; try { - return (await client.uploadBlob(file)).blobId; + uploaded = (await client.uploadBlob(file)).blobId; } catch (error) { if (isNetworkFailure(error)) throw notStartedError('upload failed'); throw error; } + // The server went away just now, and offline mode held the file again. + if (isLocalBlobId(uploaded)) throw notStartedError('upload held'); + return uploaded; } async function sendEntry(account: string, client: JMAPClient, entry: OutboxEntry): Promise<SendEmailResult> { @@ -150,15 +176,28 @@ export async function flushQueue(account: string, client: JMAPClient, deps: Flus await run(); } -/** Marks sends of closed tabs uncertain (they may have gone out). */ -export async function settleStaleSends(account?: string, now = Date.now()): Promise<void> { - const accounts = account ? [account] : useOfflineStore.getState().enabled; - await Promise.all(accounts.map((id) => getStore().area(id, 'outbox.settleStale', { before: now - SENDING_STALE_MS }))); +/** A file attached offline to something that was then discarded goes after this long. */ +export const ORPHAN_FILE_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const ORPHAN_CHECK_MS = 24 * 60 * 60 * 1000; +const orphansCheckedAt = new Map<string, number>(); + +async function dropOrphanFiles(account: string): Promise<void> { + const now = Date.now(); + if (now - (orphansCheckedAt.get(account) ?? 0) < ORPHAN_CHECK_MS) return; + orphansCheckedAt.set(account, now); + await getStore().area(account, 'outbox.dropOrphans', { olderThan: now - ORPHAN_FILE_AGE_MS }).catch(() => {}); } -/** The server refused the request itself: more tries won't change that. Outages and auth wait instead. */ -function isRequestRefusal(error: unknown): boolean { - return error instanceof Error && /^Request failed: (400|403|404|413|422)\b/.test(error.message); +/** Tests only. */ +export function forgetOrphanCheck(): void { + orphansCheckedAt.clear(); +} + +/** Marks sends of closed tabs uncertain (they may have gone out). */ +export async function settleStaleSends(account?: string, now = Date.now()): Promise<void> { + const accounts = account ? [account] : visibleAccounts(); + // One account's store in trouble doesn't hold up another's. + await Promise.allSettled(accounts.map((id) => getStore().area(id, 'outbox.settleStale', { before: now - SENDING_STALE_MS }))); } /** The changes queued before `upToSeq` (all, with null). False when the run stopped on an outage. */ @@ -167,7 +206,6 @@ async function sendPatches(account: string, client: JMAPClient, deps: FlushDeps, if (pending.length === 0) return true; const io: PatchIo = { request: (calls: MethodCall[], using: string[]) => client.requestNetworkOnly(calls, using), - isRefusal: isRequestRefusal, }; return flushPatches(io, pending, async (outcomes: PatchOutcome[]) => { const conflicts = await getStore().area<number>(account, 'outbox.patchResults', { outcomes }); @@ -207,6 +245,10 @@ async function settleUncertain(account: string, client: JMAPClient, deps: FlushD for (const entry of outbox) { if (entry.status !== 'uncertain') continue; if (stillOffline() || !deps.allowed()) return false; + if (entry.lastAttemptAt && Date.now() - entry.lastAttemptAt < LOOKUP_WAIT_MS) { + deps.retryLater?.(); + continue; + } let decision: RetryDecision; try { decision = await lookUp(client, entry); @@ -271,8 +313,13 @@ async function flushLocked(account: string, client: JMAPClient, deps: FlushDeps) deps.notify({ kind: 'send-failed', subject: entry.summary.subject, error: { code } }); continue; } - if (isNetworkFailure(error) || (error instanceof Error && error.name === 'SendUnconfirmedError')) { - // It may have reached the server: looked up before it goes again. + if (waitsForNextTry(error)) { + // Nothing the user can do about it, and the copy on the server stays useful. + await updateOutboxEntry(entry, { status: 'waiting' }); + deps.retryLater?.(); + return; + } + if (mayHaveArrived(error)) { await updateOutboxEntry(entry, { status: 'uncertain' }); deps.retryLater?.(); return; @@ -290,6 +337,7 @@ async function flushLocked(account: string, client: JMAPClient, deps: FlushDeps) } if (stillOffline() || !deps.allowed()) return; await sendPatches(account, client, deps, null); + await dropOrphanFiles(account); } finally { flushing.delete(account); } diff --git a/lib/offline/migrate-legacy.ts b/lib/offline/migrate-legacy.ts index 9be4c2d8f..809249946 100644 --- a/lib/offline/migrate-legacy.ts +++ b/lib/offline/migrate-legacy.ts @@ -145,7 +145,8 @@ async function readAccount(db: IDBDatabase, key: CryptoKey | null, account: stri if (item.kind === 'raw') messageId = rawIds.get(item.blobId); // Not sent yet: it can still get a fixed id. One that left under the old // code had another id, which nothing here knows. - else if (status === 'waiting' && typeof item.args[6] === 'string') messageId = generateMessageId(item.args[6]); + // Only its domain is used, and the sender may be written "Name <address>". + else if (status === 'waiting' && typeof item.args[6] === 'string') messageId = generateMessageId(item.args[6].replace(/>.*$/, '')); part.entries.push({ summary: payload.summary, item, @@ -193,6 +194,20 @@ export function moveLegacyOutbox(): Promise<void> { return moving; } +/** Messages an earlier version left in its database. For a browser the store can't run in, which can't move them. */ +export async function strandedLegacyMessages(): Promise<number> { + if (typeof indexedDB === 'undefined') return 0; + const db = await openLegacy().catch(() => null); + if (!db) return 0; + try { + if (!storeNamesHold(db, 'queue')) return 0; + const records = await result(db.transaction('queue', 'readonly').objectStore('queue').getAll()) as Array<{ kind?: string }>; + return records.filter((record) => record.kind !== 'patch').length; + } finally { + db.close(); + } +} + /** Tests only: forget that this page already ran it. */ export function forgetLegacyMove(): void { moving = null; diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index cc6b0c945..453b54be8 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -11,16 +11,19 @@ * - Changes and sends made offline wait in the store's outbox and go out in * order once the server answers again and the organisation's policy was * fetched afresh (flush.ts). - * - Everything is wiped at a full sign-out and the automatic sign-outs - * (lib/sign-out-cleanup.ts deletes the store and its keys), for one account - * when it signs out, is removed or switches offline mode off, and when the - * organisation forbids it (checked on every policy fetch). An account the - * app drops by itself (its session cookie ran out, say) keeps its data for - * the next sign-in. Unsent messages a sign-out deletes without the user's - * word are named (lost.ts). + * - Everything is wiped at a full sign-out, at the session limits and when the + * server really refuses the login (`invalid_grant`), by + * lib/sign-out-cleanup.ts; for one account when it signs out, is removed or + * switches offline mode off, and when the organisation forbids it (checked on + * every policy fetch). An account the app drops by itself because it couldn't + * confirm the login (an outage, an unknown refusal, a session cookie that ran + * out) keeps its data for 30 days or the next sign-in, hidden from everyone + * else. Unsent messages a sign-out deletes without the user's word are named + * (lost.ts). */ import { IS_LITE } from '@/lib/lite'; +import { desktopJmapBase } from '@/lib/desktop/jmap-route'; import { devicesForAccount, signedInAccountIds } from '@/lib/device-policy'; import { accountSubjectPending, useAccountSubjects } from '@/lib/org/account-subjects'; import { labelsPolicyFor } from '@/lib/outgoing/classification'; @@ -37,7 +40,7 @@ import { useEmailStore } from '@/stores/email-store'; import { usePolicyStore, whenPolicySettled } from '@/stores/policy-store'; import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup'; import { generateMessageId } from '@/lib/jmap/client'; -import { setOfflineHooks, type OfflineClientRef, type OfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks'; +import { SendNotStartedError, setOfflineHooks, type OfflineClientRef, type OfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks'; import type { SendEmailResult } from '@/lib/jmap/types'; import { setOutboxHooks, type OutboxHooks } from './outbox-hooks'; import { @@ -53,18 +56,24 @@ import { } from './queue'; import { clearHandoffs } from '@/lib/launch-handoff'; import { + clearEvicted, + EVICTED_KEEP_MS, forgetAccountState, forgetAllAccountState, + forgetMailSnapshot, + isEvicted, isOfflineNow, OFFLINE_ACCOUNTS_KEY, - OFFLINE_CHOICES_KEY, OFFLINE_HEALTH_KEY, + OFFLINE_SCOPE_KEY, readEnabledFrom, + readEvicted, readStoredChoices, readStoredHealth, serverKey, setServerDown, useOfflineStore, + visibleAccounts, writeChoice, writeEnabled, writeHealth, @@ -74,10 +83,10 @@ import { } from './state'; import { effectiveScope, offlineDefaultOn, olderThanCap, platformDefault, userChoiceFor, type EffectiveScope } from './scope'; import { isStandaloneDisplayMode } from '@/lib/standalone-display'; -import { readPersistence, requestPersistence, safariMayDelete, wasEvicted } from './storage-health'; +import { readPersistence, requestPersistence, safariMayDelete } from './storage-health'; import { nextProgress } from './sync-progress'; -import { flushQueue, settleStaleSends } from './flush'; -import { LEGACY_DB_NAME, moveLegacyOutbox } from './migrate-legacy'; +import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from './flush'; +import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy'; import { deleteShellCaches, tellServiceWorker } from './shell'; import { rememberLost } from './lost'; @@ -89,7 +98,9 @@ export type OfflineNotice = | { kind: 'lost'; subjects: string[] } | { kind: 'conflict'; count: number } | { kind: 'storage-failed' } - | { kind: 'evicted'; lost: number } + | { kind: 'store-unavailable' } + | { kind: 'store-reset'; lost: number } + | { kind: 'legacy-stranded' } | { kind: 'default-on' } | { kind: 'refused-delete' } | { kind: 'refused-other' } @@ -120,6 +131,7 @@ function hasIndexedDb(): boolean { /** Offline mode is possible here at all: not in Lite, with what the store needs. */ export function offlineSupported(): boolean { if (IS_LITE || !hasIndexedDb()) return false; + if (useOfflineStore.getState().storageBlocked) return false; try { return typeof crypto !== 'undefined' && !!crypto.subtle && getStore().supported(); } catch { @@ -168,6 +180,16 @@ function onlineClientFor(account: string) { /** A full disk stays full: once in a while is enough to say so. */ const STORAGE_NOTICE_MS = 30 * 60_000; let lastStorageNotice = 0; +let lastUnavailableNotice = 0; + +/** The store won't open: what waits in it is out of reach, and the user should know why. */ +function noteStoreUnavailable(): void { + if (useOfflineStore.getState().enabled.length === 0) return; + const now = Date.now(); + if (now - lastUnavailableNotice < STORAGE_NOTICE_MS) return; + lastUnavailableNotice = now; + notify({ kind: 'store-unavailable' }); +} const storeHooks: StorePageHooks = { inputs(account) { @@ -188,16 +210,14 @@ const storeHooks: StorePageHooks = { await client.requestNetworkOnly([['Core/echo', {}, '0']]); return client.getAuthHeader(); }, - onOpened(account, found) { - const health = useOfflineStore.getState().health[account]; - if (wasEvicted({ opened: !!health?.opened, ...found })) { - const lost = health?.pending ?? 0; - writeHealth(account, { opened: true, pending: 0, evicted: { at: Date.now(), lost } }); - notify({ kind: 'evicted', lost }); - void refreshQueueState(); - return; - } - writeHealth(account, { opened: true }); + onStoreLost(account) { + // Whoever is here wasn't signed in to a dropped account: nothing to tell them. + if (isEvicted(account)) return; + const lost = useOfflineStore.getState().health[account]?.pending ?? 0; + useOfflineStore.setState((s) => ({ storeLost: [...new Set([...s.storeLost, account])] })); + writeHealth(account, { pending: 0, reset: { at: Date.now(), lost } }); + notify({ kind: 'store-reset', lost }); + void refreshQueueState(); }, onEvent(event) { if (event.type === 'synced') { @@ -207,6 +227,7 @@ const storeHooks: StorePageHooks = { return { lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at }, storageFull, progress }; }); writeHealth(event.account, { lastSync: event.at }); + forgetMailSnapshot(event.account); void refreshUsage(); // A pass also sends what the calendar and contacts have waiting. void refreshQueueState(); @@ -269,7 +290,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', signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) }); + await fetch(`${desktopJmapBase(serverUrl.replace(/\/+$/, ''))}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit', signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) }); if (setServerDown(serverUrl, false)) scheduleFlushAll(500); return false; } catch { @@ -452,6 +473,8 @@ const outbox: OutboxHooks = { shouldQueue(client) { const account = accountForClient(client); if (!account || !offlineActive(account)) return false; + // A local copy has no way to the server, whatever the probes last said. + if (client.isOfflineOnly?.()) return true; return isOfflineNow(client.getServerUrl()) || (typeof navigator !== 'undefined' && navigator.onLine === false); }, async queueEmail(client, args) { @@ -494,6 +517,8 @@ const outbox: OutboxHooks = { const file = await heldFile(account, attachment.blobId); if (!file) throw Object.assign(new Error('A file attached offline is no longer on this device'), { name: 'OfflineFileMissingError' }); const { blobId } = await real.uploadBlob(file); + // The server went away just now, and offline mode held the file again. + if (isLocalBlobId(blobId)) throw new SendNotStartedError(); out.push({ ...attachment, blobId }); } return out; @@ -532,14 +557,15 @@ export async function refreshQueueState(): Promise<void> { if (!offlineSupported()) return; // Nothing is kept for an account that hasn't it on; and opening the // store would create it (on the sign-in page, after a sign-out). - if (useOfflineStore.getState().enabled.length === 0) { + if (visibleAccounts().length === 0) { useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [] }); return; } try { // Sends of a tab closed on the way: uncertain, so they can be looked up. await settleStaleSends(); - const { patches, patchesBy, outbox: entries, conflicts } = await readQueue(); + const { failed, patches, patchesBy, outbox: entries, conflicts } = await readQueue(); + if (failed.length > 0) noteStoreUnavailable(); const rows: OutboxRow[] = entries.map((entry) => ({ id: entry.id, account: entry.account, @@ -549,17 +575,23 @@ export async function refreshQueueState(): Promise<void> { status: entry.status, error: entry.error, })); - const { enabled } = useOfflineStore.getState(); - const areas = await Promise.all(enabled.map(readAreaState)); - useOfflineStore.setState({ outbox: rows, pendingChanges: patches, conflicts, areaConflicts: areas.flatMap((area) => area.conflicts) }); + // What an account's store can't tell right now is still there. + const unread = useOfflineStore.getState().outbox.filter((row) => failed.includes(row.account)); + const accounts = visibleAccounts(); + const areas = await Promise.all(accounts.map(readAreaState)); + useOfflineStore.setState({ outbox: [...unread, ...rows], pendingChanges: patches, conflicts, areaConflicts: areas.flatMap((area) => area.conflicts) }); + if (rows.some((row) => row.status === 'sending')) checkStaleSendsLater(); // What a browser clearing the store would lose. Counted here, and the - // count is all that's kept outside the store. - enabled.forEach((account, index) => { + // count is all that's kept outside the store. A store that couldn't be + // read keeps its last count. + accounts.forEach((account, index) => { + if (failed.includes(account)) return; const sends = rows.filter((row) => row.account === account).length; writeHealth(account, { pending: sends + (patchesBy[account] ?? 0) + areas[index].pending }); }); } catch { // The store is being deleted, or can't start. + noteStoreUnavailable(); } } @@ -607,13 +639,13 @@ export async function dismissAreaConflict(conflict: AreaConflict): Promise<void> } export async function refreshUsage(): Promise<void> { - const { enabled } = useOfflineStore.getState(); - if (!offlineSupported() || enabled.length === 0) return; + const accounts = visibleAccounts(); + if (!offlineSupported() || accounts.length === 0) return; try { - const usage = await Promise.all(enabled.map((account) => getStore().area<StoreUsage>(account, 'store.usage'))); + const usage = await Promise.all(accounts.map((account) => getStore().area<StoreUsage>(account, 'store.usage'))); useOfflineStore.setState({ usageBytes: usage.reduce((sum, { bytes }) => sum + bytes, 0), - usage: Object.fromEntries(enabled.map((account, index) => [account, usage[index]])), + usage: Object.fromEntries(accounts.map((account, index) => [account, usage[index]])), }); } catch { /* deleted meanwhile */ @@ -668,7 +700,7 @@ export function scheduleFlushAll(delayMs = 1_000): void { /** How long after a failed policy fetch the outbox asks again. */ const POLICY_RETRY_MS = 60_000; /** A lost response with the server still there: nothing else would start the lookup. */ -const LOOKUP_AGAIN_MS = 30_000; +const LOOKUP_AGAIN_MS = LOOKUP_WAIT_MS + 1_000; /** A send held by the server for undo, from the outbox: the undo toast as after any send. */ function offerUndo(entry: OutboxEntry, result: SendEmailResult): void { @@ -795,7 +827,8 @@ export async function clearAccount(account: string): Promise<void> { } catch { // No store: nothing to remove. The key is gone in any case. } - writeHealth(account, { opened: false, pending: 0, evicted: undefined, lastSync: undefined }); + useOfflineStore.setState((s) => ({ storeLost: s.storeLost.filter((id) => id !== account) })); + writeHealth(account, { pending: 0, reset: undefined, lastSync: undefined, clearedAt: Date.now() }); useOfflineStore.setState((s) => { const { [account]: _usage, ...usage } = s.usage; const { [account]: _full, ...storageFull } = s.storageFull; @@ -818,7 +851,7 @@ export function chooseScope(account: string, patch: UserChoice): void { /** The store's scope follows the page's answer. Nothing happens in the store if it's the same as before. */ function pushScope(account: string): void { - if (!useOfflineStore.getState().enabled.includes(account) || !scopeDecided(account) || !offlineActive(account)) return; + if (!visibleAccounts().includes(account) || !scopeDecided(account) || !offlineActive(account)) return; const scope: StoreScope = scopeForAccount(account).scope; void getStore().setScope(account, scope).then(() => refreshUsage()).catch(() => {}); } @@ -846,9 +879,10 @@ function applyDefaultOn(): void { } } -/** The evicted warning was read. */ -export function dismissEvicted(account: string): void { - writeHealth(account, { evicted: undefined }); +/** The warning that the store came back empty was read. */ +export function dismissStoreReset(account: string): void { + useOfflineStore.setState((s) => ({ storeLost: s.storeLost.filter((id) => id !== account) })); + writeHealth(account, { reset: undefined }); } export { readPersistence, requestPersistence, safariMayDelete }; @@ -879,6 +913,28 @@ async function unsentSubjects(scope: string): Promise<string[]> { return rows.filter((row) => scope === 'all' || row.account === scope).map((row) => row.subject); } +/** How long a sign-out waits for the store's word on what would be lost. */ +const UNSENT_READ_MS = 2_000; + +/** What a sign-out of `scope` would delete: messages, and edits the server hasn't got. */ +export async function unsentCount(scope: string, waitMs = UNSENT_READ_MS): Promise<{ messages: number; changes: number }> { + const shown = useOfflineStore.getState(); + const known = { messages: shown.outbox.filter((row) => scope === 'all' || row.account === scope).length, changes: shown.pendingChanges }; + const accounts = (scope === 'all' ? visibleAccounts() : [scope]).filter((account) => shown.enabled.includes(account)); + if (accounts.length === 0) return known; + const pendingIn = (account: string, area: string) => getStore().area<number>(account, `${area}.pending`).catch(() => 0); + const read = (async () => { + const { failed, outbox, patches } = await readQueue(scope === 'all' ? undefined : scope); + const edits = await Promise.all(accounts.flatMap((account) => [pendingIn(account, 'calendar'), pendingIn(account, 'contacts')])); + return { + // An account that can't answer counts with what it last said. + messages: outbox.length + shown.outbox.filter((row) => failed.includes(row.account)).length, + changes: patches + edits.reduce((sum, count) => sum + count, 0), + }; + })().catch(() => known); + return Promise.race([read, new Promise<typeof known>((resolve) => setTimeout(() => resolve(known), waitMs))]); +} + /** Names the unsent messages a wipe of `scope` deletes, unless the user agreed. */ async function nameLost(scope: string, stillSignedIn: boolean): Promise<void> { if (named.has(scope) || named.has('all')) return; @@ -900,10 +956,13 @@ export async function wipeAccount(account: string): Promise<void> { const timer = syncTimers.get(account); if (timer) clearTimeout(timer); syncTimers.delete(account); + // Waited for a sign-in that never came: whoever is here now mustn't read its subjects. + if (isEvicted(account)) named.add(account); if (useOfflineStore.getState().enabled.includes(account) || useOfflineStore.getState().outbox.some((row) => row.account === account)) { await nameLost(account, useAuthStore.getState().isAuthenticated); } named.delete(account); + clearEvicted(account); if (useOfflineStore.getState().enabled.includes(account)) { writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account)); } @@ -952,6 +1011,8 @@ function scopeDecided(account: string): boolean { /** Whether the policy of an account is known well enough to decide on it. */ function policyDecided(account: string): boolean { + // Dropped, with no groups to wait for: what applies to everyone reaches it. + if (isEvicted(account)) return true; const auth = useAuthStore.getState(); if (account === auth.activeAccountId || !auth.activeAccountId) return !usePolicyStore.getState().subjectPending; // Another signed-in account: its groups have to be known first (a group @@ -966,10 +1027,14 @@ export function enforcePolicy(): void { if (!loaded) return; for (const account of useOfflineStore.getState().enabled) { if (!policyDecided(account)) continue; - if (!offlineAllowed(account)) { + // What the organisation says, not whether this window can open the store: + // a blocked store says nothing against the data in it. + if (devicesForAccount(account).noOffline) { + // Whoever is here now wasn't signed in to a dropped account. + const someoneElses = isEvicted(account); clearHandoffs(); void wipeAccount(account); - notify({ kind: 'forbidden' }); + if (!someoneElses) notify({ kind: 'forbidden' }); continue; } // A lowered cap prunes what's kept, and a raised one starts fetching. @@ -1033,6 +1098,7 @@ export function registerOfflineHooks(): void { /** Runs offline mode in this tab. Safe to call more than once. */ export function startOfflineMode(): () => void { + if (!started && !offlineSupported()) void noteStrandedMail(); if (started || !offlineSupported()) return () => {}; started = true; registerOfflineHooks(); @@ -1065,14 +1131,14 @@ export function startOfflineMode(): () => void { // there): stop here too, and don't write anything for that account. One // switched on there has to be opened here if this tab owns the store. const onStorage = (event: StorageEvent) => { - if (event.key === OFFLINE_CHOICES_KEY || event.key === null) useOfflineStore.setState({ choices: readStoredChoices() }); + if (event.key === OFFLINE_SCOPE_KEY || event.key === null) useOfflineStore.setState({ choices: readStoredChoices() }); if (event.key === OFFLINE_HEALTH_KEY || event.key === null) { const before = useOfflineStore.getState().health; const health = readStoredHealth(); useOfflineStore.setState({ health }); // Another tab cleared an account that stays on: if this tab owns the store, it opens the new one. for (const id of useOfflineStore.getState().enabled) { - if (before[id]?.opened && health[id]?.opened === false) void getStore().ensureOpen(id).catch(() => {}); + if (health[id]?.clearedAt && health[id].clearedAt !== before[id]?.clearedAt) void getStore().ensureOpen(id).catch(() => {}); } } if (event.key !== OFFLINE_ACCOUNTS_KEY && event.key !== null) return; @@ -1105,16 +1171,27 @@ export function startOfflineMode(): () => void { cleanups.push(onAccountRemoved((id, reason) => { if (reason === 'explicit') void wipeAccount(id); })); + void sweepAccountsGone(); + void checkStorageAccess(); // Session, then a sync, for every account signed in with offline mode on. + // A client that just became real is what the waiting mail was waiting for: + // the flush at the start may have run before it was connected. + const flushedFor = new WeakSet<object>(); const syncSignedIn = () => { applyDefaultOn(); + let connected = false; for (const account of useOfflineStore.getState().enabled) { const client = onlineClientFor(account); if (!client || !offlineActive(account)) continue; void getStore().ensureOpen(account).then(() => getStore().refresh(account)).catch(() => {}); scheduleSync(account, 3_000); + if (!flushedFor.has(client)) { + flushedFor.add(client); + if (!isOfflineNow(client.getServerUrl())) connected = true; + } } + if (connected) scheduleFlushAll(1_000); }; cleanups.push(useAuthStore.subscribe((state, prev) => { if (state.client !== prev.client || state.connectedAccountsRevision !== prev.connectedAccountsRevision) syncSignedIn(); @@ -1124,10 +1201,17 @@ export function startOfflineMode(): () => void { const interval = setInterval(() => { for (const account of useOfflineStore.getState().enabled) scheduleSync(account, 0); }, SYNC_INTERVAL_MS); - const reconnect = setInterval(() => { void leaveOfflineStart(); }, RECONNECT_MS); + const reconnect = setInterval(() => { + void leaveOfflineStart(); + // Nothing else tells a server that was down is back: a client that thinks + // it's down has no reason to ask it. A passing probe starts the flush. + for (const server of useOfflineStore.getState().downServers) void probeServer(server); + }, RECONNECT_MS); cleanups.push(() => { clearInterval(interval); clearInterval(reconnect); + if (staleCheck) clearTimeout(staleCheck); + staleCheck = null; }); if (useOfflineStore.getState().enabled.length > 0) { @@ -1156,26 +1240,76 @@ export function startOfflineMode(): () => void { }; } +/** + * Data no signed-in account vouches for. A dropped account's waits 30 days. One + * that left the registry on purpose before this ran (a login without "remember + * me" ends at a reload, before the host mounts) goes now, naming lost mail. + */ +async function sweepAccountsGone(now = Date.now()): Promise<void> { + const evicted = readEvicted(); + const accounts = useAccountStore.getState().accounts; + for (const account of useOfflineStore.getState().enabled) { + const at = evicted[account]; + const gone = at !== undefined ? now - at > EVICTED_KEEP_MS : !accounts.some((entry) => entry.id === account); + if (gone) await wipeAccount(account).catch(() => {}); + } +} + +let staleCheck: ReturnType<typeof setTimeout> | null = null; + +/** A send taken by a tab that closed on the way: nothing else comes back to it. */ +function checkStaleSendsLater(): void { + if (staleCheck) return; + staleCheck = setTimeout(() => { + staleCheck = null; + void refreshQueueState().then(() => scheduleFlushAll(0)); + }, SENDING_STALE_MS + 1_000); +} + +/** Firefox's private windows refuse this at once, where the worker would try for half a minute. */ +async function checkStorageAccess(): Promise<void> { + try { + await navigator.storage.getDirectory(); + } catch (error) { + if ((error as Error)?.name === 'SecurityError') useOfflineStore.setState({ storageBlocked: true }); + } +} + +let strandedNoted = false; + +/** An earlier version's unsent mail, in a browser that can't run what would move it. */ +async function noteStrandedMail(): Promise<void> { + if (strandedNoted || IS_LITE || !hasIndexedDb()) return; + if ((await strandedLegacyMessages().catch(() => 0)) === 0) return; + strandedNoted = true; + notify({ kind: 'legacy-stranded' }); +} + /** How often the store catches up while the tab is open, on top of pushes. */ export const SYNC_INTERVAL_MS = 15 * 60 * 1000; // A full sign-out deletes the store (lib/sign-out-cleanup.ts); forget what // is held in memory as well. -onFullSignOut(() => { +onFullSignOut(({ keepLocalStore }) => { for (const timer of syncTimers.values()) clearTimeout(timer); syncTimers.clear(); - getStore().stop(); + if (!keepLocalStore) getStore().stop(); confirmed.clear(); named.clear(); - useOfflineStore.setState({ - enabled: [], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usageBytes: 0, usage: {}, choices: {}, health: {}, + useOfflineStore.setState((s) => ({ + enabled: keepLocalStore ? s.enabled : [], + choices: keepLocalStore ? s.choices : {}, + health: keepLocalStore ? s.health : {}, + outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usageBytes: 0, usage: {}, storageFull: {}, progress: {}, syncing: [], lastSyncedAt: {}, startedOffline: false, - }); + })); }); // Every sign-out the user didn't agree to lose mail at (the session limits, // a refused token renewal, a removed account) names the unsent messages. -onBeforeSignOut(async (accountId) => { +onBeforeSignOut(async (accountId, { keepLocalStore }) => { + // Nothing is lost: it waits for the next sign-in. + if (keepLocalStore) return; const stillSignedIn = accountId !== 'all' && useAccountStore.getState().accounts.some((a) => a.id !== accountId); await nameLost(accountId, stillSignedIn); }); @@ -1190,18 +1324,29 @@ export async function serverUnreachable(serverUrl: string): Promise<boolean> { const SESSION_READ_TIMEOUT_MS = 5_000; /** The session kept for an account, for starting from the local copy. */ -export async function offlineSessionFor(account: string): Promise<unknown | null> { +/** A start doesn't hang on a black-holed fetch or a stuck database. */ +const BOOT_WAIT_MS = 3_000; + +function withinBootWait(work: Promise<unknown>): Promise<unknown> { + return Promise.race([work.catch(() => {}), new Promise((resolve) => setTimeout(resolve, BOOT_WAIT_MS))]); +} + +export async function offlineSessionFor(account: string, options: { withFolders?: boolean } = {}): Promise<unknown | null> { // The organisation's last word: the policy kept from the last fetch (the // fetch itself fails without a network), never the defaults. const policy = usePolicyStore.getState(); - if (!policy.loaded && !policy.fetching) await policy.fetchPolicy(); - else await whenPolicySettled(3000); - if (!offlineActive(account)) return null; + await withinBootWait(!policy.loaded && !policy.fetching ? policy.fetchPolicy() : whenPolicySettled(BOOT_WAIT_MS)); + // Still nothing to go on: the usual start, which has its own errors. + if (!usePolicyStore.getState().loaded || !offlineActive(account)) return null; registerOfflineHooks(); try { - await moveLegacyOutbox(); + // What's in the old database goes on moving behind the start. + await withinBootWait(moveLegacyOutbox()); return await Promise.race([ - getStore().area<unknown>(account, 'mail.session'), + (async () => { + if (options.withFolders && !(await getStore().area<boolean>(account, 'mail.hasFolders'))) return null; + return getStore().area<unknown>(account, 'mail.session'); + })(), new Promise<null>((resolve) => setTimeout(() => resolve(null), SESSION_READ_TIMEOUT_MS)), ]); } catch { diff --git a/lib/offline/queue.ts b/lib/offline/queue.ts index f709431eb..34f0873bf 100644 --- a/lib/offline/queue.ts +++ b/lib/offline/queue.ts @@ -9,7 +9,7 @@ import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync'; import { getStore } from '@/lib/store/client/api'; import type { ConflictSummary, OutboxError, OutboxStatus, OutboxSummary, PolicyFingerprint, SendItem, StoredConflict, StoredOutboxEntry } from '@/lib/store/outbox/items'; -import { useOfflineStore } from './state'; +import { visibleAccounts } from './state'; export { LOCAL_BLOB_PREFIX, heldBlobIds, isLocalBlobId } from '@/lib/store/outbox/items'; export type { EmailSendItem, OutboxError, OutboxStatus, OutboxSummary, PolicyFingerprint, RawSendItem, SendItem } from '@/lib/store/outbox/items'; @@ -39,10 +39,15 @@ interface StoredQueue { } /** Everything waiting for one account (or every account with it on), oldest first. */ -export async function readQueue(account?: string): Promise<{ patches: number; patchesBy: Record<string, number>; outbox: OutboxEntry[]; conflicts: ConflictEntry[] }> { - const accounts = account ? [account] : useOfflineStore.getState().enabled; - const read = await Promise.all(accounts.map(async (id) => ({ id, queue: await getStore().area<StoredQueue>(id, 'outbox.read') }))); +export async function readQueue(account?: string): Promise<{ failed: string[]; patches: number; patchesBy: Record<string, number>; outbox: OutboxEntry[]; conflicts: ConflictEntry[] }> { + const accounts = account ? [account] : visibleAccounts(); + const settled = await Promise.allSettled(accounts.map(async (id) => ({ id, queue: await getStore().area<StoredQueue>(id, 'outbox.read') }))); + const read = settled.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : [])); + const failed = accounts.filter((_, index) => settled[index].status === 'rejected'); + // Nothing could be read: that isn't an empty queue. + if (read.length === 0 && failed.length > 0) throw (settled[0] as PromiseRejectedResult).reason; return { + failed, patches: read.reduce((sum, { queue }) => sum + queue.patches, 0), patchesBy: Object.fromEntries(read.map(({ id, queue }) => [id, queue.patches])), outbox: read.flatMap(({ id, queue }) => queue.entries.map((entry) => ({ ...entry, account: id }))), diff --git a/lib/offline/scope.ts b/lib/offline/scope.ts index 5398a0161..270030c32 100644 --- a/lib/offline/scope.ts +++ b/lib/offline/scope.ts @@ -5,6 +5,7 @@ * forbids, and that includes the desktop app's default of keeping mail. */ +import { getDesktopBridge, isDesktopApp } from '@/lib/desktop/bridge'; import { DEFAULT_SCOPE, type StoreScope } from '@/lib/store/area'; import type { DevicePolicy } from '@/lib/device-policy'; import { useOfflineStore, type UserChoice } from './state'; @@ -17,10 +18,12 @@ export const WINDOW_CHOICES: readonly (number | null)[] = [30, 90, 365, null]; export const ATTACHMENT_CAP_CHOICES: readonly number[] = [5, 25, 100, 500]; /** What the old offline mode kept, for those who had it on before the local store. */ -export const LEGACY_CHOICE: UserChoice = { on: true, days: 30, attachments: true, maxAttachmentMb: 5 }; +export const LEGACY_CHOICE: UserChoice = { on: true, days: 30, attachments: true, maxAttachmentBytes: 5 * MB }; export interface PlatformDefault { on: boolean; + /** The OS policy says so either way (desktop `OfflineEnabled`, forced): no user choice applies. */ + forced?: boolean | null; scope: StoreScope; } @@ -32,6 +35,8 @@ export interface EffectiveScope { /** What the policy decides, for the settings page: these show "Set by your organisation". */ locked: { offline: boolean; + /** The switch itself: forced on or off by the OS policy. */ + forced: boolean; days: boolean; attachments: boolean; maxAttachment: boolean; @@ -39,13 +44,37 @@ export interface EffectiveScope { }; } -/** The desktop app keeps mail without asking. Browsers stay opt-in. Feature-detected: the bridge is only there in the app. */ +interface OfflineEnabledSetting { + value?: unknown; + locked?: unknown; + source?: unknown; +} + +/** `OfflineEnabled` from the desktop app's OS policies. Feature-detected: older shells have no such member. */ +function offlineEnabledPolicy(): OfflineEnabledSetting | null { + const bridge = getDesktopBridge() as { policy?: { get?: () => { OfflineEnabled?: OfflineEnabledSetting } | null | undefined } } | null; + try { + return bridge?.policy?.get?.()?.OfflineEnabled ?? null; + } catch { + return null; + } +} + +/** + * The desktop app keeps mail without asking, unless a recommended policy says + * otherwise. Browsers stay opt-in. Feature-detected: the bridge is only there in the app. + */ export function offlineDefaultOn(): boolean { - return typeof window !== 'undefined' && Boolean((window as { bulwarkDesktop?: unknown }).bulwarkDesktop); + if (!isDesktopApp()) return false; + const setting = offlineEnabledPolicy(); + if (setting && setting.source !== 'default' && typeof setting.value === 'boolean') return setting.value; + return true; } export function platformDefault(): PlatformDefault { - return { on: offlineDefaultOn(), scope: DEFAULT_SCOPE }; + const setting = offlineEnabledPolicy(); + const forced = setting?.locked && typeof setting.value === 'boolean' ? setting.value : null; + return { on: offlineDefaultOn(), forced, scope: DEFAULT_SCOPE }; } /** Null means everything, so any cap is lower. */ @@ -66,10 +95,10 @@ export function effectiveScope( ): EffectiveScope { const choice = userChoice ?? {}; const days = choice.days !== undefined ? choice.days : defaults.scope.days; - const maxAttachment = choice.maxAttachmentMb !== undefined ? choice.maxAttachmentMb * MB : defaults.scope.maxAttachmentBytes; + const maxAttachment = choice.maxAttachmentBytes ?? defaults.scope.maxAttachmentBytes; return { account, - enabled: !policy.noOffline && (choice.on ?? defaults.on), + enabled: !policy.noOffline && (defaults.forced ?? choice.on ?? defaults.on), scope: { days: cappedDays(days, policy.offlineMaxDays), attachments: !policy.noOfflineAttachments && (choice.attachments ?? defaults.scope.attachments), @@ -78,6 +107,7 @@ export function effectiveScope( }, locked: { offline: policy.noOffline, + forced: typeof defaults.forced === 'boolean', days: policy.offlineMaxDays > 0, attachments: policy.noOfflineAttachments, maxAttachment: policy.offlineMaxAttachmentMb > 0, diff --git a/lib/offline/shell.ts b/lib/offline/shell.ts index 295bdc325..fed8fa099 100644 --- a/lib/offline/shell.ts +++ b/lib/offline/shell.ts @@ -8,7 +8,7 @@ import { apiFetch, withBasePath } from '@/lib/browser-navigation'; import { currentBuildId, SW_SCOPE } from '@/lib/app-update'; /** The app's pages kept for a start without the network. */ -const SHELL_PAGES = ['/', '/mail', '/settings', '/protocol/mailto', '/protocol/webcal']; +const SHELL_PAGES = ['/', '/mail', '/settings', '/protocol/mailto', '/protocol/webcal', '/popout/compose', '/popout/message/_']; /** * The files of this tab's build. After a deploy the server lists the new diff --git a/lib/offline/state.ts b/lib/offline/state.ts index fe2969d97..ef163e927 100644 --- a/lib/offline/state.ts +++ b/lib/offline/state.ts @@ -7,36 +7,32 @@ */ import { create } from 'zustand'; -import type { StoreUsage } from '@/lib/store/area'; +import { DEFAULT_SCOPE, type StoreScope, type StoreUsage } from '@/lib/store/area'; import type { ConflictEntry, OutboxEntry, OutboxError } from './queue'; import type { PersistState } from './storage-health'; import type { SyncProgress } from './sync-progress'; /** localStorage: the local account ids that keep mail on this device. */ export const OFFLINE_ACCOUNTS_KEY = 'bulwark-offline-accounts'; -/** localStorage: what the user picked per account. Numbers and switches only. */ -export const OFFLINE_CHOICES_KEY = 'bulwark-offline-choices'; +/** localStorage: what each account chose to keep offline, on top of the defaults. Numbers and switches only. */ +export const OFFLINE_SCOPE_KEY = 'bulwark-offline-scope'; /** localStorage: what's known about each account's store with the store gone. Numbers only, never content. */ export const OFFLINE_HEALTH_KEY = 'bulwark-offline-health'; -export interface UserChoice { +export interface UserChoice extends Partial<Pick<StoreScope, 'days' | 'attachments' | 'maxAttachmentBytes'>> { /** Off here means the user turned it off: the desktop app's default doesn't turn it back on. */ on?: boolean; - /** Days of mail to keep. Null keeps everything. */ - days?: number | null; - attachments?: boolean; - maxAttachmentMb?: number; } export interface AccountHealth { - /** The store was opened for this account once. */ - opened?: boolean; /** Unsent messages and changes last counted: what an eviction loses. */ pending?: number; /** When the last sync finished. Survives a reload, unlike the running state. */ lastSync?: number; - /** The browser deleted what was kept. Stays until the user has read it. */ - evicted?: { at: number; lost: number }; + /** The store came back empty (see `storeLost`). Unlike that, this stays across a reload until the user has read it. */ + reset?: { at: number; lost: number }; + /** The user cleared the account: another tab that owns the store opens the new one. */ + clearedAt?: number; } /** A calendar or contact edit that didn't go through. Lives in its own area's store. */ @@ -71,6 +67,13 @@ interface OfflineState { downServers: string[]; /** The app started without the server, from the mail on this device. */ startedOffline: boolean; + /** The browser won't hand out the private file system here: a private window, say. */ + storageBlocked: boolean; + /** + * Accounts whose store came back empty since this page loaded: the browser + * cleared their key or file. Whatever warns about eviction can go by this. + */ + storeLost: string[]; outbox: OutboxRow[]; pendingChanges: number; /** Changes the server refused, or that met a message changed on another device. */ @@ -126,13 +129,15 @@ export const useOfflineStore = create<OfflineState>()(() => ({ browserOffline: typeof navigator !== 'undefined' && navigator.onLine === false, downServers: [], startedOffline: false, + storeLost: [], + storageBlocked: false, outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usageBytes: 0, usage: {}, - choices: readRecord<UserChoice>(OFFLINE_CHOICES_KEY), + choices: readRecord<UserChoice>(OFFLINE_SCOPE_KEY), health: readRecord<AccountHealth>(OFFLINE_HEALTH_KEY), persist: 'unknown', storageFull: {}, @@ -144,7 +149,7 @@ export const useOfflineStore = create<OfflineState>()(() => ({ export function writeChoice(account: string, patch: UserChoice): void { const choices = { ...useOfflineStore.getState().choices, [account]: { ...useOfflineStore.getState().choices[account], ...patch } }; useOfflineStore.setState({ choices }); - writeRecord(OFFLINE_CHOICES_KEY, choices); + writeRecord(OFFLINE_SCOPE_KEY, choices); } export function forgetAccountState(account: string): void { @@ -152,7 +157,7 @@ export function forgetAccountState(account: string): void { const without = <T,>(record: Record<string, T>) => Object.fromEntries(Object.entries(record).filter(([id]) => id !== account)); const next = { choices: without(choices), health: without(health), usage: without(usage), storageFull: without(storageFull), progress: without(progress) }; useOfflineStore.setState(next); - writeRecord(OFFLINE_CHOICES_KEY, next.choices); + writeRecord(OFFLINE_SCOPE_KEY, next.choices); writeRecord(OFFLINE_HEALTH_KEY, next.health); } @@ -167,7 +172,7 @@ export function writeHealth(account: string, patch: AccountHealth): void { /** The state of other tabs, after a storage event. */ export function readStoredChoices(): Record<string, UserChoice> { - return readRecord<UserChoice>(OFFLINE_CHOICES_KEY); + return readRecord<UserChoice>(OFFLINE_SCOPE_KEY); } export function readStoredHealth(): Record<string, AccountHealth> { @@ -177,7 +182,7 @@ export function readStoredHealth(): Record<string, AccountHealth> { /** Nothing is kept for anyone any more. */ export function forgetAllAccountState(): void { useOfflineStore.setState({ choices: {}, health: {}, usage: {}, storageFull: {}, progress: {}, areaConflicts: [] }); - writeRecord(OFFLINE_CHOICES_KEY, {}); + writeRecord(OFFLINE_SCOPE_KEY, {}); writeRecord(OFFLINE_HEALTH_KEY, {}); } @@ -191,6 +196,76 @@ export function writeEnabled(enabled: string[]): void { } } +/** What the user chose, over the defaults. The organisation's caps come on top: scopeForAccount in offline-mode.ts. */ +export function offlineScopeFor(account: string): StoreScope { + try { + const { on: _on, ...chosen } = (JSON.parse(localStorage.getItem(OFFLINE_SCOPE_KEY) ?? '{}') as Record<string, UserChoice>)[account] ?? {}; + return { ...DEFAULT_SCOPE, ...chosen }; + } catch { + return DEFAULT_SCOPE; + } +} + +/** localStorage: accounts the app dropped by itself, and when. Their data waits for a new sign-in. */ +export const OFFLINE_EVICTED_KEY = 'bulwark-offline-evicted'; +/** How long that data waits. */ +export const EVICTED_KEEP_MS = 30 * 24 * 60 * 60 * 1000; + +export function readEvicted(): Record<string, number> { + try { + const parsed = JSON.parse(localStorage.getItem(OFFLINE_EVICTED_KEY) ?? '{}') as unknown; + return parsed && typeof parsed === 'object' ? parsed as Record<string, number> : {}; + } catch { + return {}; + } +} + +function writeEvicted(evicted: Record<string, number>): void { + try { + if (Object.keys(evicted).length > 0) localStorage.setItem(OFFLINE_EVICTED_KEY, JSON.stringify(evicted)); + else localStorage.removeItem(OFFLINE_EVICTED_KEY); + } catch { + /* storage unavailable */ + } +} + +/** Only an account that keeps mail here has anything to wait for. */ +export function markEvicted(account: string, now = Date.now()): void { + if (useOfflineStore.getState().enabled.includes(account)) writeEvicted({ ...readEvicted(), [account]: now }); +} + +export function clearEvicted(account: string): void { + const evicted = readEvicted(); + if (!(account in evicted)) return; + delete evicted[account]; + writeEvicted(evicted); +} + +export function isEvicted(account: string): boolean { + return account in readEvicted(); +} + +/** The accounts whose data may show here: not those waiting for a sign-in, which may be someone else's. */ +export function visibleAccounts(): string[] { + const evicted = readEvicted(); + return useOfflineStore.getState().enabled.filter((account) => !(account in evicted)); +} + +/** + * Drops the plain-text list kept in localStorage for a quick start (email-store) + * once the store can do that job. Until an account's first sync finishes it's + * what stays on screen, so turning the store on alone doesn't remove it. + */ +export function forgetMailSnapshot(account: string): void { + try { + const raw = localStorage.getItem('email-snapshot'); + if (raw && (JSON.parse(raw) as { account?: unknown } | null)?.account !== account) return; + localStorage.removeItem('email-snapshot'); + } catch { + localStorage.removeItem('email-snapshot'); + } +} + export function serverKey(url: string): string { return url.replace(/\/+$/, '').toLowerCase(); } diff --git a/lib/offline/storage-health.ts b/lib/offline/storage-health.ts index 6205be58d..6f5e30e98 100644 --- a/lib/offline/storage-health.ts +++ b/lib/offline/storage-health.ts @@ -48,13 +48,3 @@ export function safariMayDelete(userAgent: string = typeof navigator === 'undefi if (/iPhone|iPad|iPod/.test(userAgent)) return true; return /Macintosh/.test(userAgent) && /Safari\//.test(userAgent) && !/Chrome|Chromium|Edg\/|OPR\//.test(userAgent); } - -/** - * Whether the browser took away what was kept for an account: its key record - * with no file, or no key where one was made. A file with no key can't be read - * by anyone and is swept without a word; if the account had been opened, that - * is the second case. On a first open neither applies. - */ -export function wasEvicted(state: { opened: boolean; hadKey: boolean; fileExisted: boolean }): boolean { - return state.hadKey ? !state.fileExisted : state.opened; -} diff --git a/lib/popout/channel.ts b/lib/popout/channel.ts new file mode 100644 index 000000000..eb3c78134 --- /dev/null +++ b/lib/popout/channel.ts @@ -0,0 +1,272 @@ +/** + * What the windows of this browser say to each other about pop-outs. A + * BroadcastChannel, because a window opened by the desktop bridge has no + * opener, and a pop-out outlives a reload of the window that opened it. + * + * Every same-origin page hears this channel, and that includes privileged + * plugin frames (untrusted ones have an opaque origin and don't). So no + * credential goes on it in the clear: session answers are sealed for the + * window that asked (lib/popout/sealed.ts), and a refused credential is named + * by its fingerprint. Draft text does go on it, as it does for the plugins that + * see drafts anyway. + */ + +import type { ComposerDraftData } from '@/components/email/email-composer'; +import type { MessagePriority } from '@/lib/message-priority'; +import type { ComposePopoutPayload } from './compose-payload'; +import { canSealSessions, createSessionOpener, credentialFingerprint, type SealedAnswer } from './sealed'; + +export const POPOUT_CHANNEL = 'bulwark:popout-windows'; + +/** One connected login, as the window that holds it sees it. */ +export interface SharedAccountSession { + accountId: string; + serverUrl: string; + username: string; + authMode: 'basic' | 'token' | 'oauth'; + authHeader: string; + /** The window started offline: it has no credential to share (#523). */ + offlineOnly?: boolean; +} + +export interface SharedSessionAnswer { + activeAccountId: string | null; + sessions: SharedAccountSession[]; +} + +/** The send's outcome, for the main window to say and to offer Undo. The pop-out has closed by then. */ +export interface PopoutSentNotice { + accountId: string | null; + scheduled?: boolean; + queued?: boolean; + filingError?: boolean; + rejectedRecipients?: string; + /** stores/email-store.ts PendingUndoSend. */ + pendingUndoSend?: { + submissionId: string; + emailId?: string; + identityId?: string; + sendAt: string; + isSmime: boolean; + localAccountId?: string; + submissionAccountId?: string; + } | null; +} + +/** + * A draft whose window is closing, ready to save. A request started while a + * window closes doesn't survive, and saving takes several, so another window + * does it. The arguments of JMAPClient.createDraft, as plain data. + */ +export interface RescuedDraft { + /** The login to save through; absent, the window's active one. */ + localAccountId?: string; + to: string[]; + subject: string; + body: string; + cc: string[]; + bcc: string[]; + identityId?: string; + fromEmail?: string; + /** The version this one replaces. */ + draftId?: string; + attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; + fromName?: string; + htmlBody?: string; + inReplyTo?: string[]; + references?: string[]; + options?: { priority?: MessagePriority; previousDraftAccountId?: string; classification?: string | null }; + /** The composer's state: where the text goes when the save fails. */ + state: ComposerDraftData; + /** For a reload of that window to ask which draft id came of it. */ + rescueKey?: string; +} + +export type PopoutMessage = + | { type: 'session-request'; requestId: string; publicKey: JsonWebKey; accountId?: string; rejectedFingerprint?: string } + | { type: 'session-answer'; requestId: string; sealed: SealedAnswer } + | { type: 'handoff-request'; handoffId: string } + | { type: 'handoff-answer'; handoffId: string; payload: ComposePopoutPayload | null } + | { type: 'handoff-rendered'; handoffId: string } + | { type: 'sent'; requestId: string; notice: PopoutSentNotice } + | { type: 'sent-ack'; requestId: string } + | { type: 'mail-changed'; accountId: string | null } + | { type: 'state-change' } + | { type: 'draft-rescue'; draft: RescuedDraft } + | { type: 'rescue-query'; rescueKey: string } + | { type: 'rescue-answer'; rescueKey: string; draftId: string | null } + | { type: 'main-ping' } + | { type: 'main-pong'; windowId: string; accountIds: string[] } + | { type: 'main-gone'; windowId: string } + | { type: 'account-removed'; accountId: string }; + +export interface PopoutChannel { + post(message: PopoutMessage): void; + close(): void; +} + +/** Null without BroadcastChannel: pop-outs aren't offered then (lib/popout/support.ts). */ +export function openPopoutChannel(onMessage: (message: PopoutMessage) => void): PopoutChannel | null { + if (typeof BroadcastChannel === 'undefined') return null; + let channel: BroadcastChannel; + try { + channel = new BroadcastChannel(POPOUT_CHANNEL); + } catch { + return null; + } + channel.onmessage = (event: MessageEvent) => { + const message = event.data as PopoutMessage | null; + if (message && typeof message.type === 'string') onMessage(message); + }; + return { + post(message) { + try { + channel.postMessage(message); + } catch { + // The channel is closed, or the page is going away. + } + }, + close() { + channel.close(); + }, + }; +} + +function newId(): string { + return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + +export { newId as newPopoutId }; + +/** How long a pop-out waits for a main window before it goes on without one. */ +export const SESSION_ANSWER_MS = 3000; + +/** + * The session of a window that is signed in. With `accountId`, only a window + * holding that login answers, so the first answer is one that can be used. + * With `rejectedAuthHeader` the server just refused that credential: the main + * window renews it first when it still holds the same one. Null when no main + * window answers in time. + */ +export async function requestWindowSession( + options: { accountId?: string; rejectedAuthHeader?: string } = {}, + timeoutMs: number = SESSION_ANSWER_MS, +): Promise<SharedSessionAnswer | null> { + if (!canSealSessions()) return null; + const opener = await createSessionOpener(); + const rejectedFingerprint = options.rejectedAuthHeader ? await credentialFingerprint(options.rejectedAuthHeader) : undefined; + return new Promise((resolve) => { + const requestId = newId(); + let timer: ReturnType<typeof setTimeout> | null = null; + const finish = (answer: SharedSessionAnswer | null) => { + if (timer) clearTimeout(timer); + channel?.close(); + resolve(answer); + }; + const channel = openPopoutChannel((message) => { + if (message.type !== 'session-answer' || message.requestId !== requestId) return; + void opener.open<SharedSessionAnswer>(message.sealed).then(finish, () => finish(null)); + }); + if (!channel) { + resolve(null); + return; + } + timer = setTimeout(() => finish(null), timeoutMs); + channel.post({ + type: 'session-request', + requestId, + publicKey: opener.publicKey, + ...(options.accountId ? { accountId: options.accountId } : {}), + ...(rejectedFingerprint ? { rejectedFingerprint } : {}), + }); + }); +} + +/** Tells a main window what was sent. True when one took it over. */ +export function announceSentToMainWindow(notice: PopoutSentNotice, timeoutMs = 500): Promise<boolean> { + return new Promise((resolve) => { + const requestId = newId(); + let timer: ReturnType<typeof setTimeout> | null = null; + const channel = openPopoutChannel((message) => { + if (message.type === 'sent-ack' && message.requestId === requestId) finish(true); + }); + const finish = (taken: boolean) => { + if (timer) clearTimeout(timer); + channel?.close(); + resolve(taken); + }; + if (!channel) { + resolve(false); + return; + } + timer = setTimeout(() => finish(false), timeoutMs); + channel.post({ type: 'sent', requestId, notice }); + }); +} + +/** Tells the main windows that this pop-out changed mail they may be showing. */ +export function announceMailChanged(accountId: string | null): void { + const channel = openPopoutChannel(() => {}); + channel?.post({ type: 'mail-changed', accountId }); + channel?.close(); +} + +/** + * The draft id a closing window's text was saved under, for the same window + * after a reload, whose own copy still names the version that replaced. Null + * when no window saved anything for it. + */ +export function askRescuedDraftId(rescueKey: string, timeoutMs = 1500): Promise<string | null> { + return new Promise((resolve) => { + let timer: ReturnType<typeof setTimeout> | null = null; + const channel = openPopoutChannel((message) => { + if (message.type === 'rescue-answer' && message.rescueKey === rescueKey) finish(message.draftId); + }); + const finish = (draftId: string | null) => { + if (timer) clearTimeout(timer); + channel?.close(); + resolve(draftId); + }; + if (!channel) { + resolve(null); + return; + } + timer = setTimeout(() => finish(null), timeoutMs); + channel.post({ type: 'rescue-query', rescueKey }); + }); +} + +/** How often a pop-out checks that a main window is there, and how long an answer counts. */ +const MAIN_PING_MS = 4000; +const MAIN_ALIVE_MS = 10_000; + +/** + * Keeps track, synchronously, of which main windows are open and which logins + * they hold: a window closing has no time to ask. Also hands a closing draft to + * one that can save it. + */ +export function watchMainWindow(): { alive(accountId?: string): boolean; rescue(draft: RescuedDraft): boolean; stop(): void } { + const windows = new Map<string, { at: number; accountIds: string[] }>(); + const channel = openPopoutChannel((message) => { + if (message.type === 'main-pong') windows.set(message.windowId, { at: Date.now(), accountIds: message.accountIds }); + if (message.type === 'main-gone') windows.delete(message.windowId); + }); + channel?.post({ type: 'main-ping' }); + const timer = setInterval(() => channel?.post({ type: 'main-ping' }), MAIN_PING_MS); + const alive = (accountId?: string) => [...windows.values()].some((entry) => + Date.now() - entry.at < MAIN_ALIVE_MS && (!accountId || entry.accountIds.includes(accountId))); + return { + alive, + rescue(draft) { + if (!channel || !alive(draft.localAccountId)) return false; + channel.post({ type: 'draft-rescue', draft }); + return true; + }, + stop() { + clearInterval(timer); + channel?.close(); + }, + }; +} diff --git a/lib/popout/compose-handoff.ts b/lib/popout/compose-handoff.ts new file mode 100644 index 000000000..aad0dee63 --- /dev/null +++ b/lib/popout/compose-handoff.ts @@ -0,0 +1,174 @@ +/** + * Moves a composer into a pop-out. The window has to open inside the click, + * but the draft is only ready once it is saved, so the opening window keeps + * a pending handoff that the pop-out asks for by id and waits on. The opening + * window lets go of its copy only once the pop-out says the composer is on + * screen, with the text in it. + */ + +import { openPopoutChannel, newPopoutId, type PopoutChannel } from './channel'; +import type { ComposePopoutPayload } from './compose-payload'; + +/** A handoff nobody asked for after this long is dropped. Above the time a save can take. */ +const PENDING_TTL_MS = 120_000; +/** How long a pop-out waits for its draft. */ +export const HANDOFF_WAIT_MS = 60_000; +/** How often a pop-out asks again: the request may cross a reload of the window that opened it. */ +const HANDOFF_RETRY_MS = 1000; +/** How long the opening window waits for the composer to be on screen before it keeps the draft. */ +export const HANDOFF_RENDER_WAIT_MS = 30_000; + +const STORAGE_PREFIX = 'bulwark-popout-compose:'; + +interface Pending { + requested: boolean; + /** Undefined until the opening window decided; null means it won't come. */ + payload?: ComposePopoutPayload | null; + rendered?: (shown: boolean) => void; + renderTimer?: ReturnType<typeof setTimeout>; + timer: ReturnType<typeof setTimeout>; +} + +const pending = new Map<string, Pending>(); +let channel: PopoutChannel | null = null; + +function deliver(handoffId: string, entry: Pending): void { + if (!entry.requested || entry.payload === undefined) return; + channel?.post({ type: 'handoff-answer', handoffId, payload: entry.payload }); + // Called off: nothing more to hear. Otherwise stay for "it is on screen". + if (entry.payload === null) forget(handoffId); +} + +function forget(handoffId: string): void { + const entry = pending.get(handoffId); + if (entry) { + clearTimeout(entry.timer); + if (entry.renderTimer) clearTimeout(entry.renderTimer); + entry.rendered?.(false); + } + pending.delete(handoffId); + if (pending.size === 0) { + channel?.close(); + channel = null; + } +} + +export interface ComposeHandoff { + id: string; + /** Hands the draft over. True once the composer is on screen in the other window; false if that doesn't happen in time. */ + complete(payload: ComposePopoutPayload, waitMs?: number): Promise<boolean>; + cancel(): void; +} + +/** Null without BroadcastChannel. */ +export function createComposeHandoff(): ComposeHandoff | null { + channel ??= openPopoutChannel((message) => { + if (message.type === 'handoff-request') { + const entry = pending.get(message.handoffId); + if (!entry) return; + entry.requested = true; + deliver(message.handoffId, entry); + } else if (message.type === 'handoff-rendered') { + const entry = pending.get(message.handoffId); + if (!entry) return; + entry.rendered?.(true); + entry.rendered = undefined; + forget(message.handoffId); + } + }); + if (!channel) return null; + + const id = newPopoutId(); + pending.set(id, { requested: false, timer: setTimeout(() => forget(id), PENDING_TTL_MS) }); + return { + id, + complete(payload, waitMs = HANDOFF_RENDER_WAIT_MS) { + const entry = pending.get(id); + if (!entry) return Promise.resolve(false); + return new Promise<boolean>((resolve) => { + entry.rendered = resolve; + entry.renderTimer = setTimeout(() => { + entry.rendered = undefined; + resolve(false); + }, waitMs); + entry.payload = payload; + deliver(id, entry); + }); + }, + cancel() { + const entry = pending.get(id); + // Also when the draft was delivered already: the pop-out is still listening. + if (!entry) return; + entry.payload = null; + entry.requested = true; + deliver(id, entry); + }, + }; +} + +/** Keeps the draft with this window, so a reload starts from it. */ +export function rememberComposePayload(handoffId: string, payload: ComposePopoutPayload): void { + try { + sessionStorage.setItem(STORAGE_PREFIX + handoffId, JSON.stringify(payload)); + } catch { + // Full or unavailable: a reload then starts from the saved draft's first state. + } +} + +/** The draft this window holds from before a reload. */ +export function rememberedComposePayload(handoffId: string): ComposePopoutPayload | null { + try { + const raw = sessionStorage.getItem(STORAGE_PREFIX + handoffId); + return raw ? (JSON.parse(raw) as ComposePopoutPayload) : null; + } catch { + return null; + } +} + +/** + * The pop-out's side. Null when the opening window called it off, or nothing + * came in time. + */ +export function receiveComposeHandoff(handoffId: string, timeoutMs: number = HANDOFF_WAIT_MS): Promise<ComposePopoutPayload | null> { + const remembered = rememberedComposePayload(handoffId); + if (remembered) return Promise.resolve(remembered); + + return new Promise((resolve) => { + let retry: ReturnType<typeof setInterval> | null = null; + let timer: ReturnType<typeof setTimeout> | null = null; + const finish = (payload: ComposePopoutPayload | null) => { + if (retry) clearInterval(retry); + if (timer) clearTimeout(timer); + listener?.close(); + resolve(payload); + }; + const listener = openPopoutChannel((message) => { + if (message.type !== 'handoff-answer' || message.handoffId !== handoffId) return; + if (message.payload) rememberComposePayload(handoffId, message.payload); + finish(message.payload); + }); + if (!listener) { + resolve(null); + return; + } + const ask = () => listener.post({ type: 'handoff-request', handoffId }); + timer = setTimeout(() => finish(null), timeoutMs); + retry = setInterval(ask, HANDOFF_RETRY_MS); + ask(); + }); +} + +/** The composer with the text is on screen here: the opening window can let go. */ +export function acknowledgeComposeHandoff(handoffId: string): void { + const sender = openPopoutChannel(() => {}); + sender?.post({ type: 'handoff-rendered', handoffId }); + sender?.close(); +} + +/** Calls `onCancel` if the opening window takes the draft back. Returns the stop. */ +export function watchHandoffCancel(handoffId: string, onCancel: () => void): () => void { + const listener = openPopoutChannel((message) => { + if (message.type === 'handoff-answer' && message.handoffId === handoffId && message.payload === null) onCancel(); + }); + return () => listener?.close(); +} diff --git a/lib/popout/compose-payload.ts b/lib/popout/compose-payload.ts new file mode 100644 index 000000000..714bfc1ea --- /dev/null +++ b/lib/popout/compose-payload.ts @@ -0,0 +1,23 @@ +/** + * What a composer needs to start in another window. Plain data: it crosses a + * BroadcastChannel and is kept in the pop-out's sessionStorage over a reload. + * Attachments are blob references, already on the server. + */ + +import type { ComposerDraftData } from '@/components/email/email-composer'; +import type { ComposePrefill } from '@/lib/plugin-compose-gate'; +import type { EmailWriteSource } from '@/stores/email-store'; +import type { ProComposerMode, ProReplyContext } from '@/stores/pro-tab-store'; + +export interface ComposePopoutPayload { + mode: ProComposerMode; + replyTo?: ProReplyContext; + initialDraftText?: string; + initialData?: ComposerDraftData | null; + prefill?: ComposePrefill | null; + /** The message replied to or forwarded, for its $answered / $forwarded flag. */ + sourceEmailId?: string | null; + sourceEmailSource?: EmailWriteSource | null; + /** The mailbox a new message was started from; picks the identity (see the composer's prop). */ + composeFromAccountEmail?: string; +} diff --git a/lib/popout/open.ts b/lib/popout/open.ts new file mode 100644 index 000000000..e1d51ea37 --- /dev/null +++ b/lib/popout/open.ts @@ -0,0 +1,100 @@ +/** + * Opens pop-out windows: one message, or a composer. Shared with the desktop + * app's quick compose, which only has to call openComposerWindow. + */ + +import { appPath } from '@/lib/deep-links'; +import '@/lib/desktop/types'; +import { createComposeHandoff, type ComposeHandoff } from './compose-handoff'; +import type { ComposePopoutPayload } from './compose-payload'; + +const SIZES = { + message: { width: 960, height: 820 }, + compose: { width: 760, height: 780 }, +} as const; + +type PopoutKind = keyof typeof SIZES; + +/** Offset from the opening window, so the new one doesn't hide it exactly. */ +const CASCADE_PX = 48; + +export function popoutFeatures(kind: PopoutKind): string { + const { width, height } = SIZES[kind]; + const left = Math.round((window.screenX || 0) + CASCADE_PX); + const top = Math.round((window.screenY || 0) + CASCADE_PX); + return `popup=yes,width=${width},height=${height},left=${left},top=${top}`; +} + +/** Windows this page opened. window.open with a name would load the address in it again. */ +const openedWindows = new Map<string, Window>(); + +/** Whether a window opened. False when a pop-up blocker said no: the caller stays as it is. */ +function openWindow(url: string, name: string, kind: PopoutKind): Window | boolean { + const bridge = window.bulwarkDesktop?.windows; + if (bridge?.open) { + bridge.open(url, { name, ...SIZES[kind] }); + return true; + } + const existing = openedWindows.get(name); + if (existing && !existing.closed) return existing; + // Not noopener: in an installed app that is what sends a window to the + // system browser (lib/standalone-display.ts). + const opened = window.open(url, name, popoutFeatures(kind)); + if (!opened) return false; + openedWindows.set(name, opened); + return opened; +} + +function focused(opened: Window | boolean): boolean { + if (opened && typeof opened !== 'boolean') opened.focus?.(); + return !!opened; +} + +function query(params: Record<string, string | null | undefined>): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) if (value) search.set(key, value); + const text = search.toString(); + return text ? `?${text}` : ''; +} + +export interface MessageWindowTarget { + emailId: string; + /** The login the message is read through. */ + localAccountId?: string | null; + /** The JMAP account holding it, for a shared or group folder. */ + ownerAccountId?: string | null; +} + +export function messagePopoutUrl(target: MessageWindowTarget): string { + return appPath(`/popout/message/${encodeURIComponent(target.emailId)}`) + + query({ account: target.localAccountId, owner: target.ownerAccountId }); +} + +/** The same message twice focuses the same window, without loading it again. */ +export function openMessageWindow(target: MessageWindowTarget): boolean { + return focused(openWindow(messagePopoutUrl(target), `bulwark-message-${target.emailId.replace(/\W/g, '_')}`, 'message')); +} + +export interface ComposerWindow { + /** Hands the draft over. True once the composer is on screen there; until then the sender keeps its copy. */ + handOver(payload: ComposePopoutPayload): Promise<boolean>; + /** Calls it off: the pop-out closes. */ + cancel(): void; +} + +/** + * Opens an empty pop-out composer that waits for its draft. Call it straight + * from the click: a browser only opens a window for a user action. Null when + * no window opened. + */ +export function openComposerWindow(localAccountId?: string | null): ComposerWindow | null { + const handoff: ComposeHandoff | null = createComposeHandoff(); + if (!handoff) return null; + const url = appPath('/popout/compose') + query({ handoff: handoff.id, account: localAccountId }); + const opened = openWindow(url, `bulwark-compose-${handoff.id}`, 'compose'); + if (!focused(opened)) { + handoff.cancel(); + return null; + } + return { handOver: handoff.complete, cancel: handoff.cancel }; +} diff --git a/lib/popout/rescued-drafts.ts b/lib/popout/rescued-drafts.ts new file mode 100644 index 000000000..98a5f7c4d --- /dev/null +++ b/lib/popout/rescued-drafts.ts @@ -0,0 +1,55 @@ +/** + * Text from a closing pop-out that couldn't be saved. It waits here, kept over + * a reload, until the mail view can put it in its "Continue draft" slot. + */ + +import type { ComposerDraftData } from '@/components/email/email-composer'; + +const STORAGE_KEY = 'bulwark-rescued-drafts'; + +const listeners = new Set<() => void>(); + +function read(): ComposerDraftData[] { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + const list: unknown = raw ? JSON.parse(raw) : []; + return Array.isArray(list) ? (list as ComposerDraftData[]) : []; + } catch { + return []; + } +} + +function write(list: ComposerDraftData[]): void { + try { + if (list.length === 0) sessionStorage.removeItem(STORAGE_KEY); + else sessionStorage.setItem(STORAGE_KEY, JSON.stringify(list)); + } catch { + // Full or unavailable: it stays for as long as this page does, below. + } +} + +let memory: ComposerDraftData[] | null = null; + +export function keepRescuedDraft(draft: ComposerDraftData): void { + memory = [...(memory ?? read()), { ...draft, unsaved: true }]; + write(memory); + for (const listener of [...listeners]) listener(); +} + +/** The oldest waiting draft, taken off the list; null when there is none. */ +export function takeRescuedDraft(): ComposerDraftData | null { + const list = memory ?? read(); + if (list.length === 0) return null; + const [first, ...rest] = list; + memory = rest; + write(rest); + return first; +} + +/** Calls `listener` when a draft comes in. Returns the unsubscribe. */ +export function onRescuedDraft(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/lib/popout/sealed.ts b/lib/popout/sealed.ts new file mode 100644 index 000000000..42fa55e5c --- /dev/null +++ b/lib/popout/sealed.ts @@ -0,0 +1,75 @@ +/** + * Credentials between windows, sealed for the window that asked. The request + * carries a throwaway public key, and the answer can only be opened with the + * private key, which is not extractable and never leaves that window. So a + * listener on the channel, such as a privileged plugin frame, hears no + * credential go by. + * + * A BroadcastChannel has no way to hand over a MessagePort, and a window the + * desktop bridge opens has no opener to send one through. + */ + +export function canSealSessions(): boolean { + return typeof crypto !== 'undefined' && !!crypto.subtle; +} + +const ECDH = { name: 'ECDH', namedCurve: 'P-256' } as const; +const AES = { name: 'AES-GCM', length: 256 } as const; + +function toBase64(bytes: ArrayBuffer | Uint8Array): string { + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + let binary = ''; + for (let i = 0; i < view.length; i++) binary += String.fromCharCode(view[i]); + return btoa(binary); +} + +function fromBase64(text: string): Uint8Array<ArrayBuffer> { + const binary = atob(text); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +export interface SealedAnswer { + publicKey: JsonWebKey; + iv: string; + data: string; +} + +export interface SessionOpener { + publicKey: JsonWebKey; + open<T>(sealed: SealedAnswer): Promise<T>; +} + +async function deriveKey(privateKey: CryptoKey, peer: JsonWebKey): Promise<CryptoKey> { + const publicKey = await crypto.subtle.importKey('jwk', peer, ECDH, false, []); + return crypto.subtle.deriveKey({ name: 'ECDH', public: publicKey }, privateKey, AES, false, ['encrypt', 'decrypt']); +} + +/** The asking window's side: a key to send along, and what opens the answer. */ +export async function createSessionOpener(): Promise<SessionOpener> { + const pair = await crypto.subtle.generateKey(ECDH, false, ['deriveKey']); + const publicKey = await crypto.subtle.exportKey('jwk', pair.publicKey); + return { + publicKey, + async open<T>(sealed: SealedAnswer): Promise<T> { + const key = await deriveKey(pair.privateKey, sealed.publicKey); + const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromBase64(sealed.iv) }, key, fromBase64(sealed.data)); + return JSON.parse(new TextDecoder().decode(plain)) as T; + }, + }; +} + +/** The answering window's side. */ +export async function sealFor(requester: JsonWebKey, value: unknown): Promise<SealedAnswer> { + const pair = await crypto.subtle.generateKey(ECDH, false, ['deriveKey']); + const key = await deriveKey(pair.privateKey, requester); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const data = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(JSON.stringify(value))); + return { publicKey: await crypto.subtle.exportKey('jwk', pair.publicKey), iv: toBase64(iv), data: toBase64(data) }; +} + +/** Says which credential the server refused without saying what it is. */ +export async function credentialFingerprint(header: string): Promise<string> { + return toBase64(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(header))); +} diff --git a/lib/popout/support.ts b/lib/popout/support.ts new file mode 100644 index 000000000..f06f22aeb --- /dev/null +++ b/lib/popout/support.ts @@ -0,0 +1,28 @@ +/** + * Whether "Open in new window" is on offer: an installed app or the desktop + * app, on a device with windows. In a browser tab, a tab does the same job + * and the app behaves as it always has. + */ + +import { IS_LITE } from '@/lib/lite'; +import { isStandaloneDisplayMode } from '@/lib/standalone-display'; +import '@/lib/desktop/types'; +import { canSealSessions } from './sealed'; + +export function hasDesktopWindows(): boolean { + return typeof window !== 'undefined' && !!window.bulwarkDesktop?.windows; +} + +/** Display mode is `browser` in Electron popups too, hence the bridge check. */ +export function popoutsAvailable(): boolean { + // Lite has no such route: a static export can't list every message id. + if (IS_LITE || typeof window === 'undefined') return false; + // Sharing the session and renewing tokens safely across windows need these. + if (typeof BroadcastChannel === 'undefined' || !navigator.locks || !canSealSessions()) return false; + if (hasDesktopWindows()) return true; + if (!isStandaloneDisplayMode()) return false; + // A home-screen app on a phone or tablet has no second window: window.open + // leaves for the browser there. + if ((window.navigator as Navigator & { standalone?: boolean }).standalone === true) return false; + return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: fine)').matches; +} diff --git a/lib/popout/window-role.ts b/lib/popout/window-role.ts new file mode 100644 index 000000000..de20ee6cf --- /dev/null +++ b/lib/popout/window-role.ts @@ -0,0 +1,68 @@ +/** + * Whether this window is a pop-out (`/[locale]/popout/...`): a single message + * or a composer in its own window, without the rail or the sidebar. + * + * A pop-out is a guest of the main window's session. It never evicts an + * account, never starts a token timer and doesn't write the account list: + * stores/auth-store.ts and stores/account-store.ts ask here. + */ + +import { withBasePath } from '@/lib/browser-navigation'; +import { locales } from '@/i18n/routing'; + +const BUILD_BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, ''); + +/** Path segments that are pages of their own, so a files path like /files/popout/compose isn't taken for a pop-out. */ +const OTHER_SECTIONS = new Set(['mail', 'calendar', 'contacts', 'files', 'settings', 'apps', 'pro', 'admin', 'protocol', 'setup', 'connector', 'login', 'auth', 'plugins']); + +export function isPopoutPath(pathname: string): boolean { + // The mount comes off first: an app served under /mail isn't on its mail page. + const inApp = BUILD_BASE_PATH && (pathname === BUILD_BASE_PATH || pathname.startsWith(`${BUILD_BASE_PATH}/`)) + ? pathname.slice(BUILD_BASE_PATH.length) + : pathname; + const segments = inApp.split('/').filter(Boolean); + const at = segments.indexOf('popout'); + if (at < 0) return false; + // Before it: nothing, or a locale, with a mount only a reverse proxy knows ahead of that. + const before = segments.slice(0, at); + if (before.length > 0 && !(locales as readonly string[]).includes(before[before.length - 1])) return false; + if (before.some((segment) => OTHER_SECTIONS.has(segment))) return false; + const rest = segments.slice(at + 1); + return (rest.length === 1 && rest[0] === 'compose') || (rest.length === 2 && rest[0] === 'message'); +} + +export function isPopoutWindow(): boolean { + return typeof window !== 'undefined' && isPopoutPath(window.location.pathname); +} + +/** + * localStorage for the stores a pop-out shares with the main window, without + * its writes: a pop-out on account B would otherwise switch the main window + * to B at its next reload, and write back a list older than the main + * window's. Throws on the server like a bare `localStorage` does, which + * createJSONStorage takes as "no storage". + */ +export function mainWindowStorage(): Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> { + if (typeof localStorage === 'undefined') throw new Error('No localStorage here'); + return { + getItem: (key) => localStorage.getItem(key), + setItem: (key, value) => { + if (!isPopoutWindow()) localStorage.setItem(key, value); + }, + removeItem: (key) => { + if (!isPopoutWindow()) localStorage.removeItem(key); + }, + }; +} + +/** + * Closes the pop-out. A script may only close a window a script opened, so a + * pop-out address opened by hand goes to the app instead. + */ +export function closePopoutWindow(): void { + if (typeof window === 'undefined') return; + window.close(); + window.setTimeout(() => { + if (!window.closed) window.location.replace(withBasePath('/')); + }, 300); +} diff --git a/lib/protocol-handlers/session.ts b/lib/protocol-handlers/session.ts index da6941939..ba9d39365 100644 --- a/lib/protocol-handlers/session.ts +++ b/lib/protocol-handlers/session.ts @@ -1,3 +1,4 @@ +import { isStandaloneDisplayMode } from "@/lib/standalone-display"; import type { ParsedMailto } from "./mailto"; import type { ParsedWebcal } from "./webcal"; import { matchSurface } from "@/lib/deep-links"; @@ -136,9 +137,7 @@ function getMailtoClientPriority(info: ProtocolClientInfo): number { } function getDefaultProtocolClientInfo(): ProtocolClientInfo { - const nav = navigator as Navigator & { standalone?: boolean }; - const standalone = window.matchMedia?.("(display-mode: standalone)").matches || nav.standalone === true; - return { path: window.location.pathname, standalone, clientId: BROWSER_CLIENT_ID }; + return { path: window.location.pathname, standalone: isStandaloneDisplayMode(), clientId: BROWSER_CLIENT_ID }; } async function requestMailtoViaServiceWorker(value: ParsedMailto, timeoutMs: number): Promise<boolean> { diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 17534302b..68f52791c 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -36,28 +36,43 @@ const SIGNED_OUT_STORAGE_KEYS = [ // Offline mode's "Keep mail on this device" (lib/offline/state.ts): the // next person signing in here starts with it off. 'bulwark-offline-accounts', + // Which accounts' data waits for a new sign-in, and until when. + 'bulwark-offline-evicted', + 'bulwark-store-opened', // What each account chose to keep, and what's known about its store. - 'bulwark-offline-choices', + 'bulwark-offline-scope', 'bulwark-offline-health', ]; +const LOCAL_STORE_KEYS = new Set(['bulwark-offline-accounts', 'bulwark-offline-evicted', 'bulwark-store-opened', 'bulwark-offline-scope', 'bulwark-offline-health']); + const SIGNED_OUT_SIGNAL = 'bulwark:signed-out'; +export { SIGNED_OUT_SIGNAL as SIGNED_OUT_KEY }; // Stores the auth store can't import (they import it) clear themselves here. -const signedOutCleanups = new Set<() => void>(); +const signedOutCleanups = new Set<(options: SignOutCleanupOptions) => void>(); + +export interface SignOutCleanupOptions { + /** + * Keep the local store and its keys. For a sign-out the app decided on + * because it couldn't confirm the login (an outage, an unknown refusal), or + * when an account that was dropped that way still has data here. + */ + keepLocalStore?: boolean; +} /** Run `cleanup` when everyone signs out in this tab. */ -export function onFullSignOut(cleanup: () => void): void { +export function onFullSignOut(cleanup: (options: SignOutCleanupOptions) => void): void { signedOutCleanups.add(cleanup); } // Work that has to reach the server before an account signs out (changes // still queued for it). The auth store waits for it within its sign-out // budget, next to the plugins' onBeforeLogout. -const beforeSignOutTasks = new Set<(accountId: string) => Promise<void>>(); +const beforeSignOutTasks = new Set<(accountId: string, options: SignOutCleanupOptions) => Promise<void>>(); /** Run `task` before an account signs out; `accountId` is 'all' when every account does. */ -export function onBeforeSignOut(task: (accountId: string) => Promise<void>): () => void { +export function onBeforeSignOut(task: (accountId: string, options: SignOutCleanupOptions) => Promise<void>): () => void { beforeSignOutTasks.add(task); return () => beforeSignOutTasks.delete(task); } @@ -84,14 +99,15 @@ export async function passSignOutGates(accountId: string): Promise<boolean> { return true; } -export async function runBeforeSignOut(accountId: string): Promise<void> { - await Promise.all([...beforeSignOutTasks].map((task) => task(accountId).catch(() => {}))); +export async function runBeforeSignOut(accountId: string, options: SignOutCleanupOptions = {}): Promise<void> { + await Promise.all([...beforeSignOutTasks].map((task) => task(accountId, options).catch(() => {}))); } /** Remove the accounts' leftovers from browser storage. */ -export function purgeSignedOutData(): void { +export function purgeSignedOutData(options: SignOutCleanupOptions = {}): void { if (typeof window === 'undefined') return; for (const key of SIGNED_OUT_STORAGE_KEYS) { + if (options.keepLocalStore && LOCAL_STORE_KEYS.has(key)) continue; try { window.localStorage.removeItem(key); } catch { @@ -114,18 +130,20 @@ export function purgeSignedOutData(): void { // Offline mode (#523): the local store with the mail and the outbox, the keys // it was encrypted with, and the app shell the service worker kept. Also the // database of older versions, in case it wasn't moved yet. - void purgeStoreFiles(); - try { - window.indexedDB?.deleteDatabase('bulwark-offline'); - } catch { - /* storage unavailable */ + if (!options.keepLocalStore) { + void purgeStoreFiles(); + try { + window.indexedDB?.deleteDatabase('bulwark-offline'); + } catch { + /* storage unavailable */ + } + forgetOfflineShell(); } - forgetOfflineShell(); // Files and links on their way into a draft or an import. clearHandoffs(); for (const cleanup of signedOutCleanups) { try { - cleanup(); + cleanup(options); } catch { /* one store failing must not keep the others' data */ } diff --git a/lib/store/__tests__/host.test.ts b/lib/store/__tests__/host.test.ts index d4d27d46d..dfe31861d 100644 --- a/lib/store/__tests__/host.test.ts +++ b/lib/store/__tests__/host.test.ts @@ -9,25 +9,13 @@ import { openMemoryStore } from './helpers/node-adapter'; const KEY = 'a'.repeat(64); -function keyError(): Error { - const error = new Error('The local store could not be opened with this key'); - error.name = 'StoreKeyError'; - return error; -} - class FakeStorage implements HostStorage { readonly dbs = new Map<string, StorageAdapter>(); - failNext: Error | null = null; removed: string[] = []; files(): string[] { return [...this.dbs.keys()]; } async open(file: string): Promise<StorageAdapter> { - if (this.failNext) { - const error = this.failNext; - this.failNext = null; - throw error; - } let db = this.dbs.get(file); if (!db) { db = openMemoryStore(); @@ -51,53 +39,6 @@ const params = (file: string, session: unknown = FAKE_SESSION, account = 'a') => account, file, keyHex: KEY, session, scope: DEFAULT_SCOPE, invertedKeywordSort: null, auth: null, }) as Parameters<StoreHost['openAccount']>[0]; -describe('opening an account', () => { - it('says whether its file was there', async () => { - const storage = new FakeStorage(); - const first = host(storage); - expect(await first.openAccount(params('/a1.db'))).toEqual({ fileExisted: false }); - expect(await first.openAccount(params('/a1.db'))).toEqual({ fileExisted: true }); - await first.closeAll(); - expect(await host(storage).openAccount(params('/a1.db'))).toEqual({ fileExisted: true }); - }); - - it('makes no empty file while it has no session, so the next open can still tell', async () => { - const storage = new FakeStorage(); - await expect(host(storage).openAccount(params('/a1.db', null))).rejects.toThrow('No session'); - expect(storage.files()).toEqual([]); - expect(await host(storage).openAccount(params('/a1.db'))).toEqual({ fileExisted: false }); - }); - - it('starts over from a file its key can\'t read, and says the file is gone', async () => { - const storage = new FakeStorage(); - storage.dbs.set('/a1.db', openMemoryStore()); - storage.failNext = keyError(); - expect(await host(storage).openAccount(params('/a1.db'))).toEqual({ fileExisted: false }); - expect(storage.removed).toEqual(['/a1.db']); - }); - - it('never deletes a file over any other error', async () => { - const storage = new FakeStorage(); - storage.dbs.set('/a1.db', openMemoryStore()); - storage.failNext = new Error('disk I/O error'); - await expect(host(storage).openAccount(params('/a1.db'))).rejects.toThrow('disk I/O'); - expect(storage.removed).toEqual([]); - }); -}); - -describe('files with no key', () => { - it('are deleted, journals included; known ones and those in use stay', async () => { - const storage = new FakeStorage(); - const running = host(storage); - await running.openAccount(params('/ainuse.db')); - storage.dbs.set('/aknown.db', openMemoryStore()); - storage.dbs.set('/aorphan.db', openMemoryStore()); - storage.dbs.set('/aorphan.db-journal', openMemoryStore()); - expect(running.sweepFiles(['/aknown.db'])).toBeGreaterThan(0); - expect(storage.files().sort()).toEqual(['/ainuse.db', '/aknown.db']); - }); -}); - describe('removing an account', () => { it('tells every tab, once the file is gone', async () => { const storage = new FakeStorage(); diff --git a/lib/store/__tests__/mail-answers.test.ts b/lib/store/__tests__/mail-answers.test.ts index d27aaca1a..980b6ea12 100644 --- a/lib/store/__tests__/mail-answers.test.ts +++ b/lib/store/__tests__/mail-answers.test.ts @@ -263,6 +263,51 @@ describe('Email/query filters and sorts', () => { }); }); +describe('a store without its mail yet', () => { + it('says so rather than answering an empty list, until the first sync has finished', async () => { + const server = new FakeJmapMail(); + server.addEmail({ id: 'one' }); + const { engine } = engineFor(server); + const inbox = Object.keys((server.emails.get('one')!.mailboxIds) as Record<string, boolean>)[0]; + const query = () => one(engine, ['Email/query', { filter: { inMailbox: inbox } }, 'q']); + // No answer: the client reports it can't tell, and the list on screen stays. + expect(await query()).toBeUndefined(); + await engine.requestSync(); + expect((await query())![1].ids).toEqual(['one']); + // After a finished sync, an empty folder really is empty. + server.destroyEmail('one'); + await engine.requestSync(); + expect((await query())![1].ids).toEqual([]); + }); + + it('tells a start whether there are folders to show', async () => { + const server = new FakeJmapMail(); + const { engine } = engineFor(server); + expect(await engine.areaCall('mail.hasFolders', {})).toBe(false); + await engine.requestSync(); + expect(await engine.areaCall('mail.hasFolders', {})).toBe(true); + }); +}); + +describe('keyword case, as measured on both Stalwart versions', () => { + it('keeps two keywords differing only in case, and sync doesn\'t stop on them', async () => { + const server = new FakeJmapMail(); + server.addEmail({ id: 'both', keywords: { Tag: true, tag: true, $seen: true } }); + server.addEmail({ id: 'upper', keywords: { Tag: true, $SEEN: true, $seen: true } }); + server.addEmail({ id: 'none', keywords: {} }); + const { engine, db } = engineFor(server); + await engine.requestSync(); + expect(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM email')?.n).toBe(3); + const has = async (keyword: string) => (await one(engine, ['Email/query', { filter: { hasKeyword: keyword }, sort: [{ property: 'receivedAt', isAscending: false }] }, 'q']))![1].ids; + expect(await has('Tag')).toEqual(['upper', 'both']); + expect(await has('tag')).toEqual(['both']); + expect(await has('TAG')).toEqual([]); + expect(await has('$SEEN')).toEqual(['upper', 'both']); + const [, counts] = (await one(engine, ['Keyword/get', { ids: ['Tag', 'tag', '$SEEN'] }, 'k']))!; + expect(counts.list).toEqual([{ id: 'Tag', total: 2, unread: 0 }, { id: 'tag', total: 1, unread: 0 }, { id: '$SEEN', total: 2, unread: 0 }]); + }); +}); + describe('the rest of the mail methods', () => { it('answers Core/echo with what it was given', async () => { const { engine } = engineFor(new FakeJmapMail()); @@ -426,6 +471,58 @@ describe('attachments', () => { expect(refused.downloads).toEqual(['b-gone']); }); + it.each([500, 503, 429, 401])('tries a file again after a %i, which says nothing about the file', async (status) => { + class Failing extends Files { + fails = true; + override async download(jmap?: string, blobId?: string): Promise<Uint8Array> { + if (this.fails) throw new TransportError('http', 'Nope', status); + return super.download(jmap, blobId); + } + } + const server = new Failing(); + server.addEmail({ id: 'm', attachments: [{ blobId: 'b', size: 10, type: 'image/png' }] }); + const { engine, db } = engineFor(server); + await engine.requestSync(); + expect(db.get<{ refused: number }>('SELECT refused FROM email_part')?.refused).toBe(0); + server.fails = false; + server.addEmail({ id: 'n' }); + await engine.requestSync(); + expect(await engine.areaCall('mail.getBlob', { jmap: JMAP_ACCOUNT, blobId: 'b' })).not.toBeNull(); + }); + + it.each([403, 404, 410])('gives up on a file after a %i', async (status) => { + class Refusing extends Files { + override async download(): Promise<Uint8Array> { + throw new TransportError('http', 'Nope', status); + } + } + const server = new Refusing(); + server.addEmail({ id: 'm', attachments: [{ blobId: 'b', size: 10, type: 'image/png' }] }); + const { engine, db } = engineFor(server); + await engine.requestSync(); + expect(db.get<{ refused: number }>('SELECT refused FROM email_part')?.refused).toBe(1); + }); + + it('files a blob the user opened only as far as the chosen scope allows', async () => { + const server = new Files(); + server.addEmail({ id: 'm', attachments: [{ blobId: 'b-kept', size: 10, type: 'image/png' }] }); + const data = new Uint8Array(10); + const record = { jmap: JMAP_ACCOUNT, blobId: 'b-kept', type: 'image/png', data }; + + const none = engineFor(server, { ...EVERYTHING, attachments: false }); + await none.engine.requestSync(); + expect(await none.engine.areaCall('mail.recordBlob', record)).toBe(false); + expect(await none.engine.areaCall('mail.getBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-kept' })).toBeNull(); + + const small = engineFor(server, { ...EVERYTHING, attachments: true, maxAttachmentBytes: 9 }); + await small.engine.requestSync(); + expect(await small.engine.areaCall('mail.recordBlob', record)).toBe(false); + + const fits = engineFor(server, { ...EVERYTHING, attachments: true, maxAttachmentBytes: 10 }); + await fits.engine.requestSync(); + expect(await fits.engine.areaCall('mail.recordBlob', record)).toBe(true); + }); + it('files a blob the user opened only under a kept message: never a stray file', async () => { const server = new Files(); server.addEmail({ id: 'm', attachments: [{ blobId: 'b-kept', size: 99_999_999, type: 'image/png' }] }); diff --git a/lib/store/__tests__/outbox.test.ts b/lib/store/__tests__/outbox.test.ts index 610974797..d820ab2d5 100644 --- a/lib/store/__tests__/outbox.test.ts +++ b/lib/store/__tests__/outbox.test.ts @@ -6,6 +6,7 @@ import { TransportError, type MethodCall } from '../engine/transport'; import { mailArea } from '../mail'; import { outboxArea } from '../outbox'; import { CLOCK_SKEW_MARGIN_MS, decideRetry, LOOKUP_TRUSTED_MS, type LookupArgs } from '../outbox/dedupe'; +import { addHeldFileSource } from '../outbox/calls'; import { flushPatches, type PatchOutcome, type PendingPatch } from '../outbox/flush-patches'; import type { StoredConflict, StoredOutboxEntry } from '../outbox/items'; import { mergePatches, patchConflicts } from '../outbox/patches'; @@ -34,7 +35,7 @@ function setup() { transport: server, areas: [mailArea, outboxArea], scope: { ...DEFAULT_SCOPE, days: null }, - now: () => NOW, + now: () => clock, sleep: async () => {}, }); return { server, db, engine }; @@ -43,6 +44,7 @@ function setup() { let t: ReturnType<typeof setup>; beforeEach(async () => { + clock = NOW; t = setup(); await t.engine.requestSync(); }); @@ -57,14 +59,15 @@ async function local(id: string) { return answer?.methodResponses[0][1].list[0]; } +let clock = NOW; + async function boxes() { const answer = await t.engine.answer([['Mailbox/get', {}, 'm']]); return Object.fromEntries((answer?.methodResponses[0][1].list as Array<{ id: string; totalEmails: number; unreadEmails: number }>).map((box) => [box.id, [box.totalEmails, box.unreadEmails]])); } -const io = (server: FakeJmapMail = t.server, isRefusal: (error: unknown) => boolean = () => false) => ({ +const io = (server: FakeJmapMail = t.server) => ({ request: (calls: MethodCall[]) => server.request(calls), - isRefusal, }); async function flush(ioOverride = io()) { @@ -319,9 +322,18 @@ describe('conflicts', () => { expect((await local('a'))?.subject).toBe('Aye'); }); - it('compares per property, ignoring case in keyword names', () => { - const base = { keywords: { '$Label:A': true }, mailboxIds: { x: true } }; - expect(patchConflicts({ 'keywords/$label:a': null }, base, { keywords: { '$label:a': true }, mailboxIds: { x: true } })).toBe(false); + it('compares per property, keywords the way the server does', () => { + const base = { keywords: { '$SEEN': true, '$label:A': true }, mailboxIds: { x: true } }; + // System flags fold. + expect(patchConflicts({ 'keywords/$seen': null }, base, { keywords: { $seen: true, '$label:A': true }, mailboxIds: { x: true } })).toBe(false); + // Custom keywords are case-sensitive: the server now has another one. + expect(patchConflicts({ 'keywords/$label:A': null }, base, { keywords: { '$label:a': true }, mailboxIds: { x: true } })).toBe(false); + expect(patchConflicts({ 'keywords/$label:a': true }, { keywords: {}, mailboxIds: {} }, { keywords: { '$label:A': true }, mailboxIds: {} })).toBe(false); + expect(patchConflicts({ 'keywords/$label:A': true }, { keywords: { '$label:A': true }, mailboxIds: {} }, { keywords: { '$label:a': true }, mailboxIds: {} })).toBe(true); + }); + + it('compares per property', () => { + const base = { keywords: { '$label:a': true }, mailboxIds: { x: true } }; expect(patchConflicts({ 'keywords/$seen': true }, base, { keywords: {}, mailboxIds: { x: true } })).toBe(false); expect(patchConflicts({ keywords: { $seen: true } }, base, { keywords: {}, mailboxIds: { x: true } })).toBe(true); expect(patchConflicts({ keywords: { $seen: true } }, base, { keywords: { $seen: true }, mailboxIds: { x: true } })).toBe(false); @@ -349,16 +361,54 @@ describe('when the server can\'t be reached during a flush', () => { expect(await pending()).toHaveLength(1); }); - it('turns a request the server refuses for good into a conflict, not a stuck queue', async () => { + /** Refuses the Email/set with this answer, and reads go through. */ + const refusingSet = (message: string) => ({ + request: async (calls: MethodCall[]) => { + if (calls[0][0] === 'Email/set') throw new Error(message); + return t.server.request(calls); + }, + }); + const PROBLEM = 'Request failed: 400 - {"type":"urn:ietf:params:jmap:error:notRequest","status":400}'; + + it('turns a request the server refuses for good into a conflict, and shows what the server has', async () => { await update({ a: { 'keywords/$seen': true } }); - const refusing = { - request: async () => { throw new Error('Request failed: 400 - bad'); }, - isRefusal: (error: unknown) => /^Request failed: 400/.test((error as Error).message), - }; - const { outcomes, finished } = await flush(refusing); + const { outcomes, finished } = await flush(refusingSet(PROBLEM)); expect(finished).toBe(true); expect(outcomes[0]).toMatchObject({ outcome: 'conflict', reason: 'refused' }); expect(await pending()).toEqual([]); + // The change is gone, and so is its effect here. + const answer = await t.engine.answer([['Email/get', { ids: ['a'], properties: ['keywords'] }, 'g']]); + expect(answer?.methodResponses[0][1].list[0].keywords).toEqual({}); + expect((await read()).conflicts).toHaveLength(1); + }); + + it.each([ + ['the parallel-request limit', 'Request failed: 400 - {"type":"urn:ietf:params:jmap:error:limit","limit":"maxConcurrentRequests"}'], + ['a proxy\'s error page', 'Request failed: 403 - <html>Forbidden</html>'], + ['a body that\'s no JMAP problem', 'Request failed: 413 - Payload too large'], + ['a server error', 'Request failed: 500 - oops'], + ])('keeps the change for later on %s', async (_name, message) => { + await update({ a: { 'keywords/$seen': true } }); + const { outcomes, finished } = await flush(refusingSet(message)); + expect(finished).toBe(false); + expect(outcomes).toEqual([]); + expect(await pending()).toHaveLength(1); + expect((await read()).conflicts).toEqual([]); + }); + + it('keeps the change for later when the read after a refusal fails', async () => { + await update({ a: { 'keywords/$seen': true } }); + let calls = 0; + const { finished } = await flush({ + request: async (batch: MethodCall[]) => { + calls += 1; + if (calls === 1) return t.server.request(batch); + if (batch[0][0] === 'Email/set') throw new Error(PROBLEM); + throw new TypeError('Failed to fetch'); + }, + }); + expect(finished).toBe(false); + expect(await pending()).toHaveLength(1); }); it('keeps a change made while the flush ran, and doesn\'t compare it with the state the flush caused', async () => { @@ -384,7 +434,7 @@ describe('sends', () => { expect(await t.engine.areaCall('outbox.claim', { id })).toBe(true); expect(await t.engine.areaCall('outbox.claim', { id })).toBe(false); const entry = (await read()).entries[0]; - expect(entry).toMatchObject({ status: 'sending', sendingSince: NOW, firstAttemptAt: NOW, messageId: 'fixed@example.org', item }); + expect(entry).toMatchObject({ status: 'sending', sendingSince: NOW, lastAttemptAt: NOW, messageId: 'fixed@example.org', item }); }); it('marks what a closed tab left behind uncertain', async () => { @@ -393,15 +443,30 @@ describe('sends', () => { await t.engine.areaCall('outbox.settleStale', { before: NOW }); expect((await read()).entries[0].status).toBe('sending'); await t.engine.areaCall('outbox.settleStale', { before: NOW + 1 }); - expect((await read()).entries[0].status).toBe('uncertain'); + expect((await read()).entries[0]).toMatchObject({ status: 'uncertain', firstAttemptAt: NOW }); }); it('keeps the first attempt time across retries, so the lookup covers the first copy', async () => { const id = await t.engine.areaCall('outbox.queueSend', { summary, item }) as number; await t.engine.areaCall('outbox.claim', { id }); + await t.engine.areaCall('outbox.update', { id, status: 'uncertain' }); + expect((await read()).entries[0].firstAttemptAt).toBe(NOW); + clock = NOW + 60_000; await t.engine.areaCall('outbox.update', { id, status: 'waiting' }); await t.engine.areaCall('outbox.claim', { id }); - expect((await read()).entries[0].firstAttemptAt).toBe(NOW); + await t.engine.areaCall('outbox.update', { id, status: 'uncertain' }); + const entry = (await read()).entries[0]; + expect(entry.firstAttemptAt).toBe(NOW); + expect(entry.lastAttemptAt).toBe(NOW + 60_000); + }); + + it('doesn\'t call a try that never started an attempt', async () => { + const id = await t.engine.areaCall('outbox.queueSend', { summary, item }) as number; + for (let i = 0; i < 2; i += 1) { + await t.engine.areaCall('outbox.claim', { id }); + await t.engine.areaCall('outbox.update', { id, status: 'waiting' }); + } + expect((await read()).entries[0].firstAttemptAt).toBeUndefined(); }); it('keeps order across changes and sends, and a later merge moves a change later', async () => { @@ -430,6 +495,15 @@ describe('sends', () => { expect((await read()).entries).toEqual([]); }); + it('leaves a file alone that another area holds', async () => { + const held = await t.engine.areaCall('outbox.holdFile', { name: 'draft.txt', type: 'text/plain', data: new Uint8Array(1) }) as { blobId: string }; + const stale = await t.engine.areaCall('outbox.holdFile', { name: 'old.txt', type: 'text/plain', data: new Uint8Array(1) }) as { blobId: string }; + addHeldFileSource(() => [held.blobId]); + expect(await t.engine.areaCall('outbox.dropOrphans', { olderThan: NOW + 1 })).toBe(1); + expect(await t.engine.areaCall('outbox.heldFile', { blobId: held.blobId })).not.toBeNull(); + expect(await t.engine.areaCall('outbox.heldFile', { blobId: stale.blobId })).toBeNull(); + }); + it('drops old files nothing needs', async () => { const data = new Uint8Array([1]); const orphan = await t.engine.areaCall('outbox.holdFile', { name: 'a', type: '', data }) as { blobId: string }; diff --git a/lib/store/__tests__/page-side.test.ts b/lib/store/__tests__/page-side.test.ts index c8efd1642..a828a51d1 100644 --- a/lib/store/__tests__/page-side.test.ts +++ b/lib/store/__tests__/page-side.test.ts @@ -1,9 +1,10 @@ import 'fake-indexeddb/auto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { configureStore, getStore, setStoreBackend } from '../client/api'; -import { getAccountRecord } from '../client/keys'; +import { configureStore, getStore, setStoreBackend, STORE_OPENED_KEY } from '../client/api'; +import { forgetAccountKey, getAccountRecord } from '../client/keys'; import { purgeStoreFiles } from '../client/purge'; -import { StoreClient } from '../client/store-client'; +import { StoreClient, StoreOwnerGoneError } from '../client/store-client'; +import { storeFilesToPrune } from '../worker/host'; import { FAKE_SESSION } from './helpers/fake-jmap'; /** Everything on one page: a BroadcastChannel that also reaches other objects here, as in a browser. */ @@ -25,7 +26,7 @@ class FakeChannel { class FakeWorker { static all: FakeWorker[] = []; - static opened = { fileExisted: true }; + static openResult: unknown; onmessage: ((event: MessageEvent) => void) | null = null; posted: Array<Record<string, unknown>> = []; terminated = false; @@ -34,7 +35,7 @@ class FakeWorker { } postMessage(message: Record<string, unknown>): void { this.posted.push(message); - if (message.kind === 'open') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: FakeWorker.opened } } as MessageEvent)); + if (message.kind === 'open' || message.kind === 'prune-files') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: message.kind === 'open' ? FakeWorker.openResult : undefined } } as MessageEvent)); } terminate(): void { this.terminated = true; @@ -108,6 +109,47 @@ describe('a tab that starts after the owner', () => { }); }); +describe('an owner that stopped answering', () => { + afterEach(() => vi.useRealTimers()); + + async function clientWithOwner(answers: () => boolean) { + vi.useFakeTimers(); + const owner = new FakeChannel('bulwark-store-rpc'); + owner.onmessage = (event) => { + const data = event.data as { kind: string }; + if (data.kind === 'who-owns' && answers()) owner.postMessage({ kind: 'owner-changed', owner: 'elsewhere' }); + }; + const client = new StoreClient({ basePath: '' }); + client.start(); + await vi.advanceTimersByTimeAsync(0); + return client; + } + + it('stops its calls waiting, long before their own time limit', async () => { + let answering = true; + const client = await clientWithOwner(() => answering); + const call = client.sync('a@x'); + const failed = vi.fn(); + void call.catch(failed); + await vi.advanceTimersByTimeAsync(0); + answering = false; + await vi.advanceTimersByTimeAsync(39_000); + expect(failed).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(11_000); + expect(failed.mock.calls[0][0]).toBeInstanceOf(StoreOwnerGoneError); + client.halt(); + }); + + it('leaves calls alone while the owner answers', async () => { + const client = await clientWithOwner(() => true); + const failed = vi.fn(); + void client.sync('a@x').catch(failed); + await vi.advanceTimersByTimeAsync(10 * 60_000); + expect(failed).not.toHaveBeenCalled(); + client.halt(); + }); +}); + describe('the owner tab calling its own worker', () => { it('waits for the worker on the channel, since a call posted before that is lost', async () => { const worker = new FakeChannel('bulwark-store-rpc'); @@ -166,7 +208,7 @@ describe('purgeStoreFiles', () => { const order: string[] = []; Object.defineProperty(window.navigator, 'locks', { configurable: true, - value: { request: async (name: string, run: () => Promise<void>) => { order.push(`lock ${name}`); await run(); order.push('unlock'); } }, + value: { request: async (name: string, _options: unknown, run: () => Promise<void>) => { order.push(`lock ${name}`); await run(); order.push('unlock'); } }, }); storage(async (name, options) => { order.push(`remove ${name} ${JSON.stringify(options)}`); @@ -177,6 +219,25 @@ describe('purgeStoreFiles', () => { expect((await indexedDB.databases()).map((d) => d.name)).not.toContain('bulwark-store'); }); + it('deletes what it can when another tab never lets go of the owner lock', async () => { + const remove = vi.fn(async () => {}); + storage(remove); + let waited: AbortSignal | undefined; + Object.defineProperty(window.navigator, 'locks', { + configurable: true, + value: { + // The browser gives up when the signal says so. + request: async (_name: string, options: { signal: AbortSignal }) => { + waited = options.signal; + throw new DOMException('Took too long', 'TimeoutError'); + }, + }, + }); + await purgeStoreFiles(); + expect(waited).toBeInstanceOf(AbortSignal); + expect(remove).toHaveBeenCalledTimes(1); + }); + it('waits out a worker that still holds the files, and shrugs at a pool that isn\'t there', async () => { const remove = vi.fn() .mockRejectedValueOnce(Object.assign(new Error('busy'), { name: 'NoModificationAllowedError' })) @@ -190,6 +251,12 @@ describe('purgeStoreFiles', () => { }); }); +describe('which files have no key', () => { + it('names store files with no key, and leaves anything else alone', () => { + expect(storeFilesToPrune(['/a01.db', '/a02.db', '/a02.db-journal', '/other', '/ab.DB'], ['/a02.db'])).toEqual(['/a01.db']); + }); +}); + describe('the page\'s store', () => { function allowEngine() { configureStore({ @@ -215,6 +282,84 @@ describe('the page\'s store', () => { expect(worker.terminated).toBe(true); }); + describe('when the key or the file of a store is gone', () => { + const lost = vi.fn(); + const open = async () => { + configureStore({ + inputs: () => ({ session: FAKE_SESSION, auth: 'Basic x', invertedKeywordSort: true }), + refreshAuth: async () => null, + onEvent: () => {}, + onStoreLost: lost, + }); + const store = getStore(); + await store.ensureOpen('lost@x'); + const worker = FakeWorker.all[FakeWorker.all.length - 1]; + worker.becomeOwner(); + await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'open')).toBe(true)); + await vi.waitFor(() => expect(localStorage.getItem(STORE_OPENED_KEY)).toContain('lost@x')); + store.stop(); + return worker; + }; + + beforeEach(() => { + lost.mockClear(); + localStorage.clear(); + }); + + afterEach(async () => { + await forgetAccountKey('lost@x'); + }); + + it('stays quiet about a first open, and removes nothing a key still names', async () => { + const worker = await open(); + expect(lost).not.toHaveBeenCalled(); + const prune = worker.posted.find((m) => m.kind === 'prune-files') as unknown as { keep: string[] }; + const record = await getAccountRecord('lost@x'); + expect(prune.keep).toContain(record!.file); + }); + + it('deletes the orphan and tells the page when the key went', async () => { + await open(); + const before = await getAccountRecord('lost@x'); + await forgetAccountKey('lost@x'); + const worker = await open(); + const after = await getAccountRecord('lost@x'); + expect(after!.file).not.toBe(before!.file); + const prune = worker.posted.find((m) => m.kind === 'prune-files') as unknown as { keep: string[] }; + expect(prune.keep).toContain(after!.file); + expect(prune.keep).not.toContain(before!.file); + expect(lost).toHaveBeenCalledWith('lost@x'); + }); + + it('stays quiet on a normal reopen', async () => { + await open(); + const worker = await open(); + expect(lost).not.toHaveBeenCalled(); + expect(worker.posted.some((m) => m.kind === 'prune-files')).toBe(false); + }); + + it('tells the page when the file went and the key stayed', async () => { + await open(); + FakeWorker.openResult = { created: true }; + try { + await open(); + } finally { + FakeWorker.openResult = undefined; + } + expect(lost).toHaveBeenCalledWith('lost@x'); + }); + + it('doesn\'t remember an account that was removed', async () => { + await open(); + vi.spyOn(StoreClient.prototype, 'removeAccount').mockResolvedValue(true as never); + await getStore().remove('lost@x'); + getStore().stop(); + vi.restoreAllMocks(); + await open(); + expect(lost).not.toHaveBeenCalled(); + }); + }); + it('runs the client\'s refresh when the worker needs auth, and hands the result over', async () => { allowEngine(); const store = getStore(); diff --git a/lib/store/adapters/wasm.ts b/lib/store/adapters/wasm.ts index 3a191cb50..1b6ec3555 100644 --- a/lib/store/adapters/wasm.ts +++ b/lib/store/adapters/wasm.ts @@ -117,6 +117,10 @@ class WasmAdapter implements StorageAdapter { } } +export function installMayPass(error: unknown): boolean { + return (error as Error)?.name !== 'SecurityError'; +} + /** The module and the pool, once. Only the owner gets here. */ export class WasmStorage { private constructor(private readonly sqlite3: any, private readonly pool: Pool) {} @@ -141,6 +145,8 @@ export class WasmStorage { }) as Pool; return new WasmStorage(sqlite3, pool); } catch (error) { + // The browser won't allow it here at all, a private window: waiting won't help. + if (!installMayPass(error)) throw error; lastError = error; await new Promise((resolve) => setTimeout(resolve, 100 + attempt * 50)); } diff --git a/lib/store/calendar/index.ts b/lib/store/calendar/index.ts index c5e8de162..fd30b2ef5 100644 --- a/lib/store/calendar/index.ts +++ b/lib/store/calendar/index.ts @@ -16,9 +16,9 @@ export const calendarArea: StoreArea = { }, calls: { 'calendar.flush': (ctx) => flushCalendar(ctx), - 'calendar.conflicts': (ctx) => listConflicts(ctx.db), - /** How many writes wait to go out. */ + /** Edits the server hasn't got, for the question before a sign-out. */ 'calendar.pending': (ctx) => Number(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_pending')?.n ?? 0), + 'calendar.conflicts': (ctx) => listConflicts(ctx.db), 'calendar.resolveStandIns': (ctx, params: { ids: string[] }) => resolveStandIns(ctx.db, params.ids), 'calendar.dismissConflict': (ctx, params: { id: number }) => { ctx.db.run('DELETE FROM calendar_conflict WHERE id = ?', [params.id]); diff --git a/lib/store/client/api.ts b/lib/store/client/api.ts index 7d4731871..846ac03b5 100644 --- a/lib/store/client/api.ts +++ b/lib/store/client/api.ts @@ -7,7 +7,7 @@ import type { StoreEvent, StoreScope } from '../area'; import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport'; -import { forgetAccountKey, getAccountRecord, listAccountRecords, openAccountKey, type WrapLayer } from './keys'; +import { forgetAccountKey, getAccountRecord, knownStoreFiles, openAccountKey, type WrapLayer } from './keys'; import { StoreClient } from './store-client'; export interface StoreAccountInputs { @@ -24,10 +24,10 @@ export interface StorePageHooks { /** Runs the client's refresh path; the fresh Authorization header, or null. */ refreshAuth(account: string): Promise<string | null>; onEvent(event: StoreEvent): void; - /** What opening an account found: its key from before, and its file. Owner tab only. */ - onOpened?(account: string, found: { hadKey: boolean; fileExisted: boolean }): void; /** Wrap layers on top of the WebCrypto one (desktop keychain, app lock). */ keyLayers?(): readonly WrapLayer[]; + /** An account this device kept mail for has an empty store now: its key or its file went. */ + onStoreLost?(account: string): void; } /** What lib/offline uses. A seam, so its tests run the real engine on node:sqlite. */ @@ -54,12 +54,38 @@ export interface StoreBackend { const OPEN_RETRIES = 25; const OPEN_RETRY_MS = 200; +/** + * localStorage: accounts that have had a store here. Holds nothing but the + * account's id, and it outlives what a browser may clear of the rest. + */ +export const STORE_OPENED_KEY = 'bulwark-store-opened'; + +function readOpenedAccounts(): string[] { + try { + const parsed = JSON.parse(localStorage.getItem(STORE_OPENED_KEY) ?? '[]') as unknown; + return Array.isArray(parsed) ? parsed as string[] : []; + } catch { + return []; + } +} + +function rememberOpened(account: string, opened: boolean): void { + try { + const others = readOpenedAccounts().filter((id) => id !== account); + localStorage.setItem(STORE_OPENED_KEY, JSON.stringify(opened ? [...others, account] : others)); + } catch { + // Without it a lost store goes unnoticed, and the store still works. + } +} + class PageStore implements StoreBackend { private client: StoreClient | null = null; private readonly wanted = new Map<string, StoreSession | null>(); private readonly opening = new Map<string, Promise<void>>(); /** The engine can't start in this browser: don't keep every caller waiting for an owner. */ private broken = false; + /** What the owner's engine was last told to keep. */ + private readonly sentScope = new Map<string, string>(); constructor(private readonly hooks: () => StorePageHooks | null) {} @@ -82,7 +108,6 @@ class PageStore implements StoreBackend { onOwner: () => { this.opening.clear(); for (const account of this.wanted.keys()) void this.openHere(account).catch(() => {}); - void this.sweepOrphans(); }, onNeedAuth: (account) => { void (this.hooks()?.refreshAuth(account) ?? Promise.resolve(null)) @@ -98,31 +123,24 @@ class PageStore implements StoreBackend { private reset(): void { this.client = null; + this.sentScope.clear(); this.wanted.clear(); this.opening.clear(); this.broken = false; } - /** Files whose key is gone can't be read by anyone: they only take room. */ - private async sweepOrphans(): Promise<void> { - try { - const records = await listAccountRecords(); - await this.client?.sweepFiles(records.map((record) => record.file)); - } catch { - // Tried again the next time this tab or another becomes the owner. - } - } - /** Only the owner may open: the key goes to this tab's own worker, never over the channel. */ private openHere(account: string): Promise<void> { const running = this.opening.get(account); if (running) return running; const run = (async () => { const client = this.started(); - const hadKey = Boolean(await getAccountRecord(account)); - const { keyHex, record } = await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []); + const { keyHex, record, fresh } = await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []); + // The old file, if the key alone went, can never be read again. + if (fresh) await client.pruneFiles(await knownStoreFiles()).catch(() => {}); const inputs = this.hooks()?.inputs(account) ?? { session: null, auth: null, invertedKeywordSort: null }; - const { fileExisted } = await client.openAccount({ + if (inputs.scope) this.sentScope.set(account, JSON.stringify(inputs.scope)); + const opened = await client.openAccount({ account, file: record.file, keyHex, @@ -131,7 +149,10 @@ class PageStore implements StoreBackend { invertedKeywordSort: inputs.invertedKeywordSort, auth: inputs.auth, }); - this.hooks()?.onOpened?.(account, { hadKey, fileExisted }); + if (fresh || opened?.created) { + if (readOpenedAccounts().includes(account)) this.hooks()?.onStoreLost?.(account); + } + rememberOpened(account, true); })(); this.opening.set(account, run); run.catch(() => this.opening.delete(account)); @@ -152,7 +173,12 @@ class PageStore implements StoreBackend { if (!inputs) return; if (inputs.session) client.setSession(account, inputs.session); client.setAuth(account, inputs.auth); - if (inputs.scope) void client.setScope(account, inputs.scope).catch(() => {}); + // Only a real change: setting it starts over what a full disk made the store give up on. + const scope = inputs.scope ? JSON.stringify(inputs.scope) : null; + if (inputs.scope && scope !== this.sentScope.get(account)) { + if (this.sentScope.has(account)) void client.setScope(account, inputs.scope).catch(() => {}); + this.sentScope.set(account, scope!); + } if (inputs.invertedKeywordSort !== null) client.setInvertedKeywordSort(account, inputs.invertedKeywordSort); } @@ -179,6 +205,7 @@ class PageStore implements StoreBackend { } setScope(account: string, scope: StoreScope): Promise<void> { + this.sentScope.set(account, JSON.stringify(scope)); return this.whenOpen(account, (client) => client.setScope(account, scope)); } @@ -198,6 +225,7 @@ class PageStore implements StoreBackend { async remove(account: string): Promise<void> { const record = await getAccountRecord(account); await forgetAccountKey(account); + rememberOpened(account, false); this.wanted.delete(account); this.opening.delete(account); if (record && !this.broken) await this.started().removeAccount(account, record.file); diff --git a/lib/store/client/keys.ts b/lib/store/client/keys.ts index ecd4fe4c2..9723c6710 100644 --- a/lib/store/client/keys.ts +++ b/lib/store/client/keys.ts @@ -83,6 +83,14 @@ export function newStoreFileName(): string { return `/a${toHex(crypto.getRandomValues(new Uint8Array(8)))}.db`; } +/** The file of every account with a key. Any other file in the pool can't be read by anyone. */ +export async function knownStoreFiles(): Promise<string[]> { + return withDb(async (db) => { + const records = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).getAll()) as AccountRecord[]; + return records.map((record) => record.file); + }); +} + export async function getAccountRecord(account: string): Promise<AccountRecord | null> { return withDb(async (db) => { const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined; @@ -95,7 +103,7 @@ export async function listAccountRecords(): Promise<AccountRecord[]> { } /** The account's key in hex, created on first use. Returns the record too, for the file name. */ -export async function openAccountKey(account: string, layers: readonly WrapLayer[] = []): Promise<{ keyHex: string; record: AccountRecord }> { +export async function openAccountKey(account: string, layers: readonly WrapLayer[] = []): Promise<{ keyHex: string; record: AccountRecord; fresh: boolean }> { return withDb(async (db) => { const wrapping = await wrapKey(db); const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined; @@ -109,7 +117,7 @@ export async function openAccountKey(account: string, layers: readonly WrapLayer const raw = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: asBuffer(found.iv) }, wrapping, asBuffer(data))); const keyHex = toHex(raw); raw.fill(0); - return { keyHex, record: found }; + return { keyHex, record: found, fresh: false }; } const raw = crypto.getRandomValues(new Uint8Array(32)); const iv = crypto.getRandomValues(new Uint8Array(12)); @@ -126,7 +134,7 @@ export async function openAccountKey(account: string, layers: readonly WrapLayer await promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).put(record)); const keyHex = toHex(raw); raw.fill(0); - return { keyHex, record }; + return { keyHex, record, fresh: true }; }); } diff --git a/lib/store/client/purge.ts b/lib/store/client/purge.ts index c2356dd34..10fb04dfb 100644 --- a/lib/store/client/purge.ts +++ b/lib/store/client/purge.ts @@ -10,6 +10,8 @@ import { deleteKeyDatabase } from './keys'; /** A worker just stopped may hold the files for a moment longer. */ const REMOVE_TRIES = 10; const REMOVE_WAIT_MS = 200; +/** A sign-out doesn't wait for a tab that never lets go of the owner lock. */ +const POOL_LOCK_WAIT_MS = 5_000; async function removePool(): Promise<void> { const root = await navigator.storage.getDirectory(); @@ -28,8 +30,18 @@ async function removePool(): Promise<void> { /** Deletes the pool folder, holding the owner lock so no worker can take the files meanwhile. */ export async function deleteStorePool(): Promise<void> { if (typeof navigator === 'undefined' || typeof navigator.storage?.getDirectory !== 'function') return; - if (navigator.locks) await navigator.locks.request(OWNER_LOCK, removePool); - else await removePool(); + if (!navigator.locks) { + await removePool(); + return; + } + try { + await navigator.locks.request(OWNER_LOCK, { signal: AbortSignal.timeout(POOL_LOCK_WAIT_MS) }, removePool); + } catch (error) { + if ((error as Error)?.name !== 'TimeoutError' && (error as Error)?.name !== 'AbortError') throw error; + // A tab that won't let go, stuck or asleep: the key is gone, so what can be + // deleted goes now, and what it still holds can't be read anyway. + await removePool(); + } } /** diff --git a/lib/store/client/store-client.ts b/lib/store/client/store-client.ts index 9935d6471..9ff5b2309 100644 --- a/lib/store/client/store-client.ts +++ b/lib/store/client/store-client.ts @@ -21,6 +21,9 @@ import { const CALL_TIMEOUT_MS = 60_000; const OWNER_WAIT_MS = 15_000; +/** Calls waiting on the owner ask it this often whether it's still there. */ +const OWNER_PING_MS = 10_000; +const OWNER_PING_MISSES = 3; /** A first sync of a large account runs a long time, and its promise is what ends "syncing". */ const SYNC_TIMEOUT_MS = 30 * 60_000; @@ -58,6 +61,8 @@ export class StoreClient { private isOwner = false; private readonly pending = new Map<string, Pending>(); private readonly ownerWaiters: Array<() => void> = []; + private ownerWatch: ReturnType<typeof setInterval> | null = null; + private ownerAnswered: (() => void) | null = null; private lifecycleBound = false; constructor(private readonly options: StoreClientOptions = {}) {} @@ -150,6 +155,7 @@ export class StoreClient { break; case 'owner-changed': this.owner = message.owner; + if (message.owner) this.ownerAnswered?.(); if (message.owner) this.flushOwnerWaiters(); break; case 'shutdown': @@ -175,6 +181,41 @@ export class StoreClient { } } + /** + * A tab that's frozen or stuck keeps the lock, and nothing else would tell. + * While calls wait, the owner is asked to answer; when it doesn't for a while, + * they stop waiting instead of running into their long time limits. + */ + private watchOwner(): void { + if (this.ownerWatch) return; + let unanswered = 0; + this.ownerAnswered = () => { unanswered = 0; }; + this.ownerWatch = setInterval(() => { + if (this.pending.size === 0) { + this.stopWatchingOwner(); + return; + } + if (unanswered >= OWNER_PING_MISSES) { + this.owner = null; + this.stopWatchingOwner(); + for (const [id, pending] of this.pending) { + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(new StoreOwnerGoneError()); + } + return; + } + unanswered += 1; + this.channel?.postMessage({ kind: 'who-owns' } satisfies ChannelMessage); + }, OWNER_PING_MS); + } + + private stopWatchingOwner(): void { + if (this.ownerWatch) clearInterval(this.ownerWatch); + this.ownerWatch = null; + this.ownerAnswered = null; + } + private flushOwnerWaiters(): void { for (const waiter of this.ownerWaiters.splice(0)) waiter(); } @@ -202,6 +243,7 @@ export class StoreClient { private async rpc<K extends keyof StoreRpc>(method: K, params: Parameters<StoreRpc[K]>[0], timeoutMs = CALL_TIMEOUT_MS): Promise<ReturnType<StoreRpc[K]>> { await this.waitForOwner(); + this.watchOwner(); const id = crypto.randomUUID(); const result = this.track<ReturnType<StoreRpc[K]>>(id, timeoutMs); this.channel?.postMessage({ kind: 'rpc', id, from: this.clientId, method, params } as ChannelMessage); @@ -220,12 +262,14 @@ export class StoreClient { return this.isOwner; } - openAccount(params: OpenAccountParams): Promise<{ fileExisted: boolean }> { + /** `created`: there was no database under this name. */ + openAccount(params: OpenAccountParams): Promise<{ created?: boolean } | undefined> { return this.direct({ kind: 'open', id: crypto.randomUUID(), params }); } - sweepFiles(known: string[]): Promise<number> { - return this.direct({ kind: 'sweep-files', id: crypto.randomUUID(), known }); + /** Deletes every store file but these. */ + pruneFiles(keep: string[]): Promise<void> { + return this.direct({ kind: 'prune-files', id: crypto.randomUUID(), keep }); } closeAccount(account: string): Promise<void> { @@ -266,6 +310,7 @@ export class StoreClient { this.channel = null; this.isOwner = false; this.owner = null; + this.stopWatchingOwner(); for (const [id, pending] of this.pending) { this.pending.delete(id); clearTimeout(pending.timer); diff --git a/lib/store/mail/calls.ts b/lib/store/mail/calls.ts index 7a3dbe441..bec374a14 100644 --- a/lib/store/mail/calls.ts +++ b/lib/store/mail/calls.ts @@ -62,4 +62,6 @@ export const MAIL_CALLS: Readonly<Record<string, AreaCall>> = { 'mail.usage': usage, /** The session kept with the account, for a start from the local copy. */ 'mail.session': (ctx) => ctx.session, + /** Whether a start may show the store: it has a folder list from the server. */ + 'mail.hasFolders': (ctx) => ctx.db.get("SELECT 1 AS yes FROM sync_state WHERE type = 'Mailbox' AND state IS NOT NULL") !== undefined, }; diff --git a/lib/store/mail/methods.ts b/lib/store/mail/methods.ts index 1b28508aa..8462563e4 100644 --- a/lib/store/mail/methods.ts +++ b/lib/store/mail/methods.ts @@ -8,6 +8,7 @@ import { MethodError, type MethodContext, type MethodHandler, type MethodResult import { listObjects } from '../engine/core-schema'; import type { SqlValue } from '../storage'; import { headerValue, parseHeaderProperty, type RawHeader } from './headers'; +import { keywordKey } from './records'; import { limitTextConditions, searchSnippetGet, textCondition, TEXT_FIELDS, type TextField } from './search'; type Filter = Record<string, unknown>; @@ -62,11 +63,11 @@ function filterSql(filter: Filter, jmap: string, drivenBy: string | null = null, } case 'hasKeyword': conditions.push(`EXISTS (SELECT 1 FROM email_keyword k WHERE k.email_rowid = ${rowid} AND k.keyword = ?)`); - params.push(String(value).toLowerCase()); + params.push(keywordKey(String(value))); break; case 'notKeyword': conditions.push(`NOT EXISTS (SELECT 1 FROM email_keyword k WHERE k.email_rowid = ${rowid} AND k.keyword = ?)`); - params.push(String(value).toLowerCase()); + params.push(keywordKey(String(value))); break; case 'after': conditions.push(`${receivedAt} >= ?`); @@ -145,7 +146,7 @@ function orderSql(ctx: MethodContext, sort: Comparator[]): Sql { // stored answer has to read it the way that server would. if (ctx.invertedKeywordSort) ascending = !ascending; parts.push(`EXISTS (SELECT 1 FROM email_keyword s WHERE s.email_rowid = e.rowid AND s.keyword = ?) ${ascending ? 'ASC' : 'DESC'}`); - params.push(String(level.keyword ?? '').toLowerCase()); + params.push(keywordKey(String(level.keyword ?? ''))); break; } default: @@ -185,7 +186,7 @@ function keywordLevels(ctx: MethodContext, sort: Comparator[]): KeywordLevel[] | let next = 0; for (; sort[next]?.property === 'hasKeyword'; next += 1) { const ascending = sort[next].isAscending !== false; - levels.push({ keyword: String(sort[next].keyword ?? '').toLowerCase(), first: ctx.invertedKeywordSort ? ascending : !ascending }); + levels.push({ keyword: keywordKey(String(sort[next].keyword ?? '')), first: ctx.invertedKeywordSort ? ascending : !ascending }); } if (levels.length === 0 || levels.length > MAX_KEYWORD_LEVELS) return null; return isNewestFirst(sort.slice(next)) ? levels : null; @@ -316,6 +317,9 @@ const emailQuery: MethodHandler = (ctx, args) => { ).map((row) => String(row.id)); // Stalwart's queryState is the Email state. Absent until the first sync, as allowed. const queryState = lastState(ctx, jmap, 'Email'); + // Nothing here doesn't mean nothing there: an empty folder of a first sync + // that hasn't finished mustn't paint over what's on screen. + if (ids.length === 0 && position === 0 && !queryState) throw new MethodError('serverUnavailable', 'Mail not kept yet'); const result: MethodResult = { accountId: jmap, canCalculateChanges: false, @@ -512,7 +516,7 @@ const keywordGet: MethodHandler = (ctx, args) => { const counts = new Map(rows.map((row) => [String(row.keyword), { id: String(row.keyword), total: Number(row.total), unread: Number(row.unread) }])); const wanted = Array.isArray(args.ids) ? args.ids as string[] : null; const list = wanted - ? wanted.map((id) => ({ ...(counts.get(id.toLowerCase()) ?? { total: 0, unread: 0 }), id })) + ? wanted.map((id) => ({ ...(counts.get(keywordKey(id)) ?? { total: 0, unread: 0 }), id })) : [...counts.values()]; return { accountId: jmap, diff --git a/lib/store/mail/records.ts b/lib/store/mail/records.ts index 147b5d5f2..e9d575c44 100644 --- a/lib/store/mail/records.ts +++ b/lib/store/mail/records.ts @@ -2,6 +2,18 @@ import type { StorageAdapter } from '../storage'; +/** + * How keywords compare, measured on both Stalwart versions: the IMAP system + * flags fold to lower case, every other keyword is case-sensitive, so `Tag` and + * `tag` are two keywords and one message can carry both. + */ +const SYSTEM_KEYWORDS = new Set(['$seen', '$flagged', '$answered', '$draft', '$forwarded', '$mdnsent', '$junk', '$notjunk', '$phishing', '$recent', '$deleted']); + +export function keywordKey(keyword: string): string { + const lower = keyword.toLowerCase(); + return SYSTEM_KEYWORDS.has(lower) ? lower : keyword; +} + export const MAIL_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']; export const EMAIL_LIST_PROPERTIES = [ @@ -80,7 +92,7 @@ export function upsertEmail(db: StorageAdapter, jmap: string, email: EmailObject if (on) db.run('INSERT INTO email_mailbox (email_rowid, jmap, mailbox_id, received_at) VALUES (?, ?, ?, ?)', [rowid, jmap, box, receivedAt]); } for (const [keyword, on] of Object.entries((email.keywords ?? {}) as Record<string, boolean>)) { - if (on) db.run('INSERT INTO email_keyword (email_rowid, keyword) VALUES (?, ?)', [rowid, keyword.toLowerCase()]); + if (on) db.run('INSERT OR IGNORE INTO email_keyword (email_rowid, keyword) VALUES (?, ?)', [rowid, keywordKey(keyword)]); } return rowid; } diff --git a/lib/store/mail/sync.ts b/lib/store/mail/sync.ts index d1cff16d3..f22265bee 100644 --- a/lib/store/mail/sync.ts +++ b/lib/store/mail/sync.ts @@ -380,10 +380,12 @@ async function fetchAttachments(ctx: SyncContext, jmap: string): Promise<void> { try { data = await ctx.transport.download(jmap, blobId, part.type ?? 'application/octet-stream', part.name ?? 'attachment'); } catch (error) { - if (error instanceof TransportError && error.kind === 'http') { + // Only what says this file won't come. Anything else, like a 500 or a 429, is tried at the next sync. + if (error instanceof TransportError && error.kind === 'http' && PERMANENT_DOWNLOAD_STATUSES.has(error.status ?? 0)) { ctx.db.run('UPDATE email_part SET refused = 1 WHERE jmap = ? AND blob_id = ?', [jmap, blobId]); continue; } + if (error instanceof TransportError && error.kind === 'http') return; throw error; } putBlob(ctx.db, jmap, blobId, part.type, data, Number(part.received_at)); @@ -392,6 +394,8 @@ async function fetchAttachments(ctx: SyncContext, jmap: string): Promise<void> { } } +const PERMANENT_DOWNLOAD_STATUSES = new Set([403, 404, 410]); + // ---- Identities and quotas ---- const IDENTITY_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:submission']; diff --git a/lib/store/outbox/calls.ts b/lib/store/outbox/calls.ts index d34e38949..33b1f7228 100644 --- a/lib/store/outbox/calls.ts +++ b/lib/store/outbox/calls.ts @@ -27,6 +27,7 @@ interface OutboxRow { status: string; sending_since: number | null; first_attempt_at: number | null; + last_attempt_at: number | null; payload: string; summary: string; message_id: string | null; @@ -46,6 +47,7 @@ function toEntry(row: OutboxRow): StoredOutboxEntry { ...(payload.fingerprint ? { fingerprint: payload.fingerprint } : {}), ...(row.sending_since ? { sendingSince: Number(row.sending_since) } : {}), ...(row.first_attempt_at ? { firstAttemptAt: Number(row.first_attempt_at) } : {}), + ...(row.last_attempt_at ? { lastAttemptAt: Number(row.last_attempt_at) } : {}), ...(row.error ? { error: JSON.parse(row.error) as OutboxError } : {}), ...(row.message_id ? { messageId: row.message_id } : {}), ...(row.copy_id ? { copyId: row.copy_id } : {}), @@ -101,7 +103,7 @@ function read(ctx: StoreContext): { patches: number; entries: StoredOutboxEntry[ function claim(ctx: StoreContext, params: { id: number }): boolean { const now = ctx.now(); return ctx.db.run( - `UPDATE outbox SET status = 'sending', sending_since = ?, first_attempt_at = COALESCE(first_attempt_at, ?) WHERE id = ? AND status = 'waiting'`, + `UPDATE outbox SET status = 'sending', sending_since = ?, last_attempt_at = ? WHERE id = ? AND status = 'waiting'`, [now, now, params.id], ).changes === 1; } @@ -110,11 +112,14 @@ function claim(ctx: StoreContext, params: { id: number }): boolean { function update(ctx: StoreContext, params: { id: number; status: OutboxStatus; error?: OutboxError; copyId?: string | null }): void { ctx.db.run( `UPDATE outbox SET status = ?, error = ?, sending_since = NULL, + first_attempt_at = CASE WHEN ? THEN COALESCE(first_attempt_at, last_attempt_at) ELSE first_attempt_at END, copy_id = CASE WHEN ? THEN ? ELSE copy_id END WHERE id = ?`, [ params.status, params.error ? JSON.stringify(params.error) : null, + // A try that may have reached the server is what a later lookup has to cover. + params.status === 'uncertain' ? 1 : 0, params.copyId === undefined ? 0 : 1, params.copyId ?? null, params.id, @@ -125,7 +130,8 @@ function update(ctx: StoreContext, params: { id: number; status: OutboxStatus; e /** A send a closed tab left behind may have gone out. */ function settleStale(ctx: StoreContext, params: { before: number }): number { return ctx.db.run( - `UPDATE outbox SET status = 'uncertain', sending_since = NULL WHERE status = 'sending' AND COALESCE(sending_since, 0) < ?`, + `UPDATE outbox SET status = 'uncertain', sending_since = NULL, first_attempt_at = COALESCE(first_attempt_at, last_attempt_at) + WHERE status = 'sending' AND COALESCE(sending_since, 0) < ?`, [params.before], ).changes; } @@ -156,10 +162,18 @@ function heldFile(ctx: StoreContext, params: { blobId: string }): { name: string return row ? { name: row.name ?? 'attachment', type: row.type ?? 'application/octet-stream', data: row.data } : null; } +const heldFileSources: Array<(ctx: StoreContext) => Iterable<string>> = []; + +/** Another area's records that hold files by their local id, which dropOrphans must leave alone. */ +export function addHeldFileSource(source: (ctx: StoreContext) => Iterable<string>): void { + heldFileSources.push(source); +} + /** Files held for no entry (attached offline to a message that was then discarded). SEAM: the drafts agent keeps its drafts' files here too. */ function dropOrphans(ctx: StoreContext, params: { olderThan: number }): number { return ctx.db.transaction(() => { const needed = new Set(ctx.db.all<OutboxRow>('SELECT * FROM outbox').flatMap((row) => heldBlobIds(toEntry(row).item))); + for (const source of heldFileSources) for (const id of source(ctx)) needed.add(id); let dropped = 0; for (const row of ctx.db.all<{ local_id: string }>('SELECT local_id FROM upload WHERE created_at < ?', [params.olderThan])) { if (needed.has(String(row.local_id))) continue; diff --git a/lib/store/outbox/flush-patches.ts b/lib/store/outbox/flush-patches.ts index 361cf817d..ae0901828 100644 --- a/lib/store/outbox/flush-patches.ts +++ b/lib/store/outbox/flush-patches.ts @@ -42,17 +42,26 @@ export type PatchOutcome = export interface PatchIo { request(calls: MethodCall[], using: string[]): Promise<JmapResponse>; - /** The server refused the request itself, for good: not an outage, and no use retrying. */ - isRefusal(error: unknown): boolean; } const BATCH = 200; /** Method errors that mean "not now" rather than "no". */ const RETRY_LATER = new Set(['serverUnavailable', 'serverFail', 'serverPartialFail', 'rateLimit', 'requestTooLarge']); -function isLater(error: unknown, io: PatchIo): boolean { +/** + * The server refused the request itself, for good: a JMAP problem document with + * a status that says so. `limit` is the parallel-request ceiling, which passes, + * and an error page says something about a proxy, not about this request. + */ +export function isRequestRefusal(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const problem = /^Request failed: (?:400|403|404|413|422)\b[\s\S]*?"type"\s*:\s*"urn:ietf:params:jmap:error:([A-Za-z]+)"/.exec(error.message); + return !!problem && problem[1] !== 'limit'; +} + +function isLater(error: unknown): boolean { if (error instanceof JmapMethodError) return RETRY_LATER.has(error.type); - return !io.isRefusal(error); + return !isRequestRefusal(error); } function conflict(row: PendingPatch, reason: ConflictReason, server?: EmailObject | null, detail?: string): PatchOutcome { @@ -67,14 +76,36 @@ function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); } -async function flushBatch(io: PatchIo, jmap: string, rows: PendingPatch[]): Promise<PatchOutcome[]> { +async function readRecords(io: PatchIo, jmap: string, rows: PendingPatch[]): Promise<Map<string, EmailObject>> { const read = await io.request([['Email/get', { accountId: jmap, ids: rows.map((row) => row.recordId), properties: EMAIL_LIST_PROPERTIES, }, 'g']], MAIL_USING); const [, got] = responseFor(read, 'g'); - const current = new Map((got.list as EmailObject[]).map((email) => [email.id, email])); + return new Map((got.list as EmailObject[]).map((email) => [email.id, email])); +} + +/** + * The whole batch was refused. The server's copy of each record goes with the + * conflict so that this device shows what's true again. When even that read is + * refused the record stays as it is here until the next sync. + */ +async function refusedOutcomes(io: PatchIo, jmap: string, rows: PendingPatch[], refusal: unknown): Promise<PatchOutcome[] | null> { + let current: Map<string, EmailObject> | null = null; + try { + current = await readRecords(io, jmap, rows); + } catch (error) { + if (isLater(error)) return null; + } + return rows.map((row) => { + if (current && !current.has(row.recordId)) return row.op === 'destroy' ? done(row) : conflict(row, 'gone', null); + return conflict(row, 'refused', current?.get(row.recordId), errorText(refusal)); + }); +} + +async function flushBatch(io: PatchIo, jmap: string, rows: PendingPatch[]): Promise<PatchOutcome[]> { + const current = await readRecords(io, jmap, rows); const outcomes: PatchOutcome[] = []; const update: Record<string, Patch> = {}; @@ -129,12 +160,13 @@ export async function flushPatches( for (const [jmap, rows] of byAccount) { for (let i = 0; i < rows.length; i += BATCH) { const batch = rows.slice(i, i + BATCH); - let outcomes: PatchOutcome[]; + let outcomes: PatchOutcome[] | null; try { outcomes = await flushBatch(io, jmap, batch); } catch (error) { - if (isLater(error, io)) return false; - outcomes = batch.map((row) => conflict(row, 'refused', undefined, errorText(error))); + if (isLater(error)) return false; + outcomes = await refusedOutcomes(io, jmap, batch, error); + if (!outcomes) return false; } await report(outcomes); } diff --git a/lib/store/outbox/items.ts b/lib/store/outbox/items.ts index 259c11877..e802090ee 100644 --- a/lib/store/outbox/items.ts +++ b/lib/store/outbox/items.ts @@ -62,7 +62,9 @@ export interface StoredOutboxEntry { summary: OutboxSummary; status: OutboxStatus; sendingSince?: number; + /** When the first try that may have reached the server began. */ firstAttemptAt?: number; + lastAttemptAt?: number; error?: OutboxError; item: SendItem; fingerprint?: PolicyFingerprint; diff --git a/lib/store/outbox/patches.ts b/lib/store/outbox/patches.ts index 5666623e6..e055ae7b9 100644 --- a/lib/store/outbox/patches.ts +++ b/lib/store/outbox/patches.ts @@ -4,6 +4,8 @@ * the whole map. */ +import { keywordKey } from '../mail/records'; + export type Patch = Record<string, unknown>; export interface RecordState { @@ -103,14 +105,14 @@ export function mergePatches(earlier: Patch, later: Patch): Patch { } function sameMap(a: Record<string, boolean> | undefined, b: Record<string, boolean> | undefined): boolean { - const left = Object.keys(a ?? {}).filter((key) => a?.[key]).map((key) => key.toLowerCase()).sort(); - const right = Object.keys(b ?? {}).filter((key) => b?.[key]).map((key) => key.toLowerCase()).sort(); + const left = Object.keys(a ?? {}).filter((key) => a?.[key]).map(keywordKey).sort(); + const right = Object.keys(b ?? {}).filter((key) => b?.[key]).map(keywordKey).sort(); return left.length === right.length && left.every((key, i) => key === right[i]); } function has(map: Record<string, boolean> | undefined, key: string): boolean { - const wanted = key.toLowerCase(); - return Object.keys(map ?? {}).some((name) => name.toLowerCase() === wanted && map?.[name]); + const wanted = keywordKey(key); + return Object.keys(map ?? {}).some((name) => keywordKey(name) === wanted && map?.[name]); } /** diff --git a/lib/store/outbox/schema.ts b/lib/store/outbox/schema.ts index 9ca1bb34a..2f9ed83e5 100644 --- a/lib/store/outbox/schema.ts +++ b/lib/store/outbox/schema.ts @@ -1,6 +1,15 @@ import type { Migration } from '../storage'; export const OUTBOX_MIGRATIONS: readonly Migration[] = [ + { + id: 201, + name: 'outbox_last_attempt', + sql: ` + -- When the latest try began. It becomes first_attempt_at only once a try + -- may have reached the server. + ALTER TABLE outbox ADD COLUMN last_attempt_at INTEGER; + `, + }, { id: 200, name: 'outbox', diff --git a/lib/store/worker/host.ts b/lib/store/worker/host.ts index 14002de2b..1c1d3c035 100644 --- a/lib/store/worker/host.ts +++ b/lib/store/worker/host.ts @@ -9,11 +9,14 @@ import { FetchTransport, type StoreSession } from '../engine/transport'; import type { StorageAdapter } from '../storage'; import type { OpenAccountParams, StoreRpc, StoreStatus } from './protocol'; +/** Files in the pool that belong to no key. A name that isn't a store file's is left alone. */ +export function storeFilesToPrune(files: readonly string[], keep: readonly string[]): string[] { + return files.filter((file) => /^\/a[0-9a-f]+\.db$/.test(file) && !keep.includes(file)); +} + export interface HostStorage { open(file: string, keyHex: string): Promise<StorageAdapter>; remove(file: string): boolean; - /** Database files in the pool. Their names say nothing about the accounts. */ - files(): string[]; } interface OpenAccount { @@ -46,8 +49,7 @@ export class StoreHost { constructor(private readonly options: StoreHostOptions) {} - /** Says whether the account's file was there: gone with a key still around, the browser cleared it. */ - async openAccount(params: OpenAccountParams): Promise<{ fileExisted: boolean }> { + async openAccount(params: OpenAccountParams): Promise<{ created: boolean }> { const existing = this.accounts.get(params.account); if (existing) { if (params.session) this.setSession(params.account, params.session); @@ -55,12 +57,10 @@ export class StoreHost { if (params.scope) existing.engine.setScope(params.scope); if (params.invertedKeywordSort !== null) existing.engine.setInvertedKeywordSort(params.invertedKeywordSort); void existing.engine.requestSync(); - return { fileExisted: true }; + return { created: false }; } - let fileExisted = this.options.storage.files().includes(params.file); - // Nothing to sync with yet: no empty file, or the next open would take it for the real one. - if (!fileExisted && !params.session) throw new Error('No session for this account yet'); - const db = await this.openOrReplace(params, () => { fileExisted = false; }); + const db = await this.options.storage.open(params.file, params.keyHex); + const created = !db.get("SELECT 1 AS yes FROM sqlite_master WHERE name = 'schema_migration'"); const session = params.session ?? AccountEngine.storedSession(db); if (!session) { db.close(); @@ -91,32 +91,7 @@ export class StoreHost { account.engine.setOnline(this.online); this.accounts.set(params.account, account); void account.engine.requestSync(); - return { fileExisted }; - } - - /** A file this key can't read is lost for good, whatever happened to it, and everything in it can be fetched again. */ - private async openOrReplace(params: OpenAccountParams, onReplaced: () => void): Promise<StorageAdapter> { - try { - return await this.options.storage.open(params.file, params.keyHex); - } catch (error) { - if ((error as Error)?.name !== 'StoreKeyError' || !params.session) throw error; - this.options.storage.remove(params.file); - onReplaced(); - return this.options.storage.open(params.file, params.keyHex); - } - } - - /** Deletes files no account has a key for. Nothing can read them. Files in use stay, whatever the list says. */ - sweepFiles(known: readonly string[]): number { - const keep = new Set(known); - for (const open of this.accounts.values()) keep.add(open.file); - let removed = 0; - for (const name of this.options.storage.files()) { - const file = name.replace(/-journal$/, ''); - if (keep.has(file)) continue; - if (this.options.storage.remove(file)) removed += 1; - } - return removed; + return { created }; } setAuth(account: string, auth: string | null): void { diff --git a/lib/store/worker/protocol.ts b/lib/store/worker/protocol.ts index c20577d38..561392412 100644 --- a/lib/store/worker/protocol.ts +++ b/lib/store/worker/protocol.ts @@ -78,7 +78,7 @@ export type PageToWorker = | { kind: 'init'; clientId: string; moduleUrl: string } | { kind: 'open'; id: string; params: OpenAccountParams } | { kind: 'close-account'; id: string; account: string } - | { kind: 'sweep-files'; id: string; known: string[] } + | { kind: 'prune-files'; id: string; keep: string[] } | { kind: 'set-auth'; account: string; auth: string | null } | { kind: 'set-inverted'; account: string; inverted: boolean } | { kind: 'set-session'; account: string; session: StoreSession } diff --git a/lib/store/worker/worker.ts b/lib/store/worker/worker.ts index d211fd622..2a34403ed 100644 --- a/lib/store/worker/worker.ts +++ b/lib/store/worker/worker.ts @@ -9,7 +9,7 @@ import { WasmStorage } from '../adapters/wasm'; import { STORE_AREAS } from '../areas'; import { RPC_CHANNEL, OWNER_LOCK, type ChannelMessage, type PageToWorker, type WorkerToPage } from './protocol'; -import { StoreHost } from './host'; +import { StoreHost, storeFilesToPrune } from './host'; const scope = self as unknown as { postMessage(message: WorkerToPage): void; @@ -115,17 +115,18 @@ scope.addEventListener('message', async (event) => { replyError(message.id, error); } break; - case 'close-account': + case 'prune-files': try { - await requireHost().closeAccount(message.account); + for (const file of storeFilesToPrune(storage?.files() ?? [], message.keep)) storage?.remove(file); reply(message.id); } catch (error) { replyError(message.id, error); } break; - case 'sweep-files': + case 'close-account': try { - reply(message.id, requireHost().sweepFiles(message.known)); + await requireHost().closeAccount(message.account); + reply(message.id); } catch (error) { replyError(message.id, error); } diff --git a/lib/theme-color-meta.ts b/lib/theme-color-meta.ts index bcfd88881..dd9064e3f 100644 --- a/lib/theme-color-meta.ts +++ b/lib/theme-color-meta.ts @@ -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; diff --git a/lib/web-push.ts b/lib/web-push.ts index f1739b441..4ab2e43de 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -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 && diff --git a/lib/window-controls-overlay.ts b/lib/window-controls-overlay.ts new file mode 100644 index 000000000..481e2e2b1 --- /dev/null +++ b/lib/window-controls-overlay.ts @@ -0,0 +1,68 @@ +/** + * Window Controls Overlay: an installed app (and the desktop app's + * titleBarOverlay window) draws the page up to the top of the window and lays + * the native minimise, maximise and close buttons over it. + * + * Asks navigator.windowControlsOverlay, never the media query: + * `(display-mode: window-controls-overlay)` is false in Electron with + * titleBarOverlay, while the API and env(titlebar-area-*) work there. + * + * app/globals.css gives --titlebar-inset its first value from + * env(titlebar-area-height, 0px), so the page has the right top padding from + * the first paint, before any script runs. This module keeps the variable in + * step with the API afterwards, which is what a browser or bridge without the + * env() values relies on. + */ + +export interface TitlebarArea { + x: number; + y: number; + width: number; + height: number; +} + +interface OverlayLike extends EventTarget { + visible: boolean; + getTitlebarAreaRect?: () => { x: number; y: number; width: number; height: number }; +} + +export const TITLEBAR_INSET_VAR = '--titlebar-inset'; + +function overlay(): OverlayLike | null { + if (typeof navigator === 'undefined') return null; + const value = (navigator as Navigator & { windowControlsOverlay?: OverlayLike }).windowControlsOverlay; + return value ?? null; +} + +/** The strip the page may draw in next to the window's controls, or null when there is no overlay. */ +export function readTitlebarArea(): TitlebarArea | null { + const current = overlay(); + if (!current?.visible) return null; + const rect = current.getTitlebarAreaRect?.(); + if (!rect || !(rect.height > 0)) return null; + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; +} + +/** + * Sets --titlebar-inset for this area. With no area the inline value goes, so + * the stylesheet's value (0 without an overlay) is what counts again. + */ +export function applyTitlebarInset(area: TitlebarArea | null, root: HTMLElement = document.documentElement): void { + if (area) root.style.setProperty(TITLEBAR_INSET_VAR, `${area.height}px`); + else root.style.removeProperty(TITLEBAR_INSET_VAR); +} + +/** + * Calls `listener` now and whenever the overlay's size or visibility changes + * (`geometrychange`). Without the API it calls it once with null and listens + * to nothing, so a browser without it runs no extra code. Returns the + * unsubscribe. + */ +export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void): () => void { + listener(readTitlebarArea()); + const current = overlay(); + if (!current) return () => {}; + const onChange = () => listener(readTitlebarArea()); + current.addEventListener('geometrychange', onChange); + return () => current.removeEventListener('geometrychange', onChange); +} diff --git a/locales/ar/common.json b/locales/ar/common.json index d27a9b2c9..01aa69392 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "إغلاق الملاحظة حول {subject}", "banner": "تغييرات لم تُطبَّق: {count}", "toast": "تغييرات أُجريت دون اتصال ولم تُطبَّق: {count}. يوضح صندوق الصادر أيها.", - "storage_failed": "لم تعد هناك مساحة على هذا الجهاز للبريد دون اتصال. أفرغ مساحة أو احتفظ بأقل من الإعدادات." + "storage_failed": "لم تعد هناك مساحة على هذا الجهاز للبريد دون اتصال. أفرغ مساحة أو احتفظ بأقل من الإعدادات.", + "signout_changes_title": "هل تريد تسجيل الخروج مع تغييرات غير محفوظة؟", + "signout_changes": "تغييرات لم تصل إلى الخادم بعد: {count}. يؤدي تسجيل الخروج إلى حذفها.", + "store_unavailable": "لا يمكن فتح البريد دون اتصال على هذا الجهاز الآن. يبقى البريد غير المرسل هنا ويواصل التطبيق المحاولة.", + "store_reset": "تعذرت قراءة البريد دون اتصال على هذا الجهاز، لذا يجري تنزيله من جديد. ما لم يُرسل قد فُقد.", + "legacy_stranded": "لا يمكن لهذا المتصفح فتح البريد غير المرسل المحفوظ من إصدار سابق. قد يعيده تحديث المتصفح.", + "store_private": "لا يمكن للنوافذ الخاصة الاحتفاظ بالبريد للاستخدام دون اتصال.", + "server_not_answering": "خادم البريد لا يستجيب. حاول مرة أخرى بعد قليل." }, "offlineStorage": { "keep_description": "يحتفظ ببريدك وتقويمك وجهات اتصالك والملفات التي تختارها، مشفّرة في هذا المتصفح. دون اتصال يمكنك قراءة البريد ووضع علامات عليه ونقله، وما تكتبه ينتظر في صندوق الصادر. يؤدي تسجيل الخروج إلى إزالة كل ذلك.", @@ -6550,5 +6557,32 @@ "next_issue": "الخطأ الإملائي أو النحوي التالي", "previous_issue": "الخطأ الإملائي أو النحوي السابق" } + }, + "pwaWindows": { + "open_in_window": "فتح في نافذة جديدة", + "save_all_to_folder": "حفظ الكل في مجلد…", + "saved_all": "{count, plural, zero {لم يُحفظ أي مرفق في {folder}.} one {تم حفظ مرفق واحد في {folder}.} two {تم حفظ مرفقين في {folder}.} few {تم حفظ # مرفقات في {folder}.} many {تم حفظ # مرفقًا في {folder}.} other {تم حفظ # مرفق في {folder}.}}", + "saved_partial": "تم حفظ {saved} من أصل {total} مرفقات في {folder}.", + "save_failed": "تعذر حفظ المرفقات في {folder}.", + "popup_blocked": "حظر المتصفح النافذة الجديدة. اسمح بالنوافذ المنبثقة لهذا الموقع وحاول مرة أخرى.", + "opening": "جارٍ الفتح…", + "session_ended_title": "تم تسجيل خروجك في هذه النافذة", + "session_ended_body": "افتح التطبيق وسجّل الدخول، ثم افتح هذه النافذة مرة أخرى.", + "open_app": "فتح التطبيق", + "close_window": "إغلاق النافذة", + "message_gone_title": "لم تعد هذه الرسالة موجودة", + "message_gone_body": "تم حذفها أو نقلها إلى حساب آخر.", + "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": "عنوان الخادم هذا غير صالح." } } diff --git a/locales/ca/common.json b/locales/ca/common.json index e9c57770f..fa74fe5ad 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Descarta la nota sobre {subject}", "banner": "Canvis no aplicats: {count}", "toast": "Canvis fets sense connexió que no s'han aplicat: {count}. La safata de sortida indica quins.", - "storage_failed": "Aquest dispositiu no té més espai per al correu sense connexió. Allibereu espai o conserveu-ne menys a la configuració." + "storage_failed": "Aquest dispositiu no té més espai per al correu sense connexió. Allibereu espai o conserveu-ne menys a la configuració.", + "signout_changes_title": "Voleu tancar la sessió amb canvis sense desar?", + "signout_changes": "Canvis que encara no són al servidor: {count}. En tancar la sessió s'eliminen.", + "store_unavailable": "El correu sense connexió d'aquest dispositiu no es pot obrir ara. El correu no enviat es queda aquí i l'aplicació ho continua provant.", + "store_reset": "No s'ha pogut llegir el correu sense connexió d'aquest dispositiu, així que es torna a baixar. El que no s'havia enviat s'ha perdut.", + "legacy_stranded": "Aquest navegador no pot obrir el correu no enviat desat per una versió anterior. Actualitzar el navegador el pot recuperar.", + "store_private": "Les finestres privades no poden desar correu per fer-lo servir sense connexió.", + "server_not_answering": "El servidor de correu no respon. Torneu-ho a provar d'aquí a un moment." }, "offlineStorage": { "keep_description": "Desa el vostre correu, calendari, contactes i els fitxers que trieu, xifrats en aquest navegador. Sense connexió podeu llegir, marcar i moure correu, i el que escriviu espera a la safata de sortida. En tancar la sessió s'esborra tot.", @@ -6550,5 +6557,32 @@ "next_issue": "Error d'ortografia o de gramàtica següent", "previous_issue": "Error d'ortografia o de gramàtica anterior" } + }, + "pwaWindows": { + "open_in_window": "Obre en una finestra nova", + "save_all_to_folder": "Desa-ho tot en una carpeta…", + "saved_all": "{count, plural, one {S’ha desat # adjunt a {folder}.} other {S’han desat # adjunts a {folder}.}}", + "saved_partial": "S’han desat {saved} de {total} adjunts a {folder}.", + "save_failed": "No s’han pogut desar els adjunts a {folder}.", + "popup_blocked": "El navegador ha blocat la finestra nova. Permet les finestres emergents per a aquest lloc i torna-ho a provar.", + "opening": "S’està obrint…", + "session_ended_title": "Has tancat la sessió en aquesta finestra", + "session_ended_body": "Obre l’aplicació i inicia la sessió, i després torna a obrir aquesta finestra.", + "open_app": "Obre l’aplicació", + "close_window": "Tanca la finestra", + "message_gone_title": "Aquest missatge ja no hi és", + "message_gone_body": "S’ha suprimit o s’ha mogut a un altre compte.", + "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." } } diff --git a/locales/cs/common.json b/locales/cs/common.json index da3998c40..69ffed51b 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Zavřít poznámku o {subject}", "banner": "Nepoužité změny: {count}", "toast": "Nepoužité změny provedené offline: {count}. Které to jsou, ukazuje pošta k odeslání.", - "storage_failed": "Na tomto zařízení už není místo pro poštu offline. Uvolněte místo nebo v nastavení ponechte méně." + "storage_failed": "Na tomto zařízení už není místo pro poštu offline. Uvolněte místo nebo v nastavení ponechte méně.", + "signout_changes_title": "Odhlásit se s neuloženými změnami?", + "signout_changes": "Změny, které ještě nejsou na serveru: {count}. Odhlášení je smaže.", + "store_unavailable": "Poštu offline na tomto zařízení teď nelze otevřít. Neodeslaná pošta tu zůstává a aplikace to dál zkouší.", + "store_reset": "Poštu offline na tomto zařízení se nepodařilo přečíst, proto se stahuje znovu. Co nebylo odesláno, je pryč.", + "legacy_stranded": "Tento prohlížeč neotevře neodeslanou poštu uloženou starší verzí. Aktualizace prohlížeče ji může vrátit.", + "store_private": "Soukromá okna nemohou uchovávat poštu pro použití offline.", + "server_not_answering": "Poštovní server neodpovídá. Zkuste to za chvíli znovu." }, "offlineStorage": { "keep_description": "Uchovává vaši poštu, kalendář, kontakty a vybrané soubory zašifrované v tomto prohlížeči. Offline můžete poštu číst, označovat a přesouvat a to, co napíšete, čeká v odesílané poště. Odhlášení vše odstraní.", @@ -6550,5 +6557,32 @@ "next_issue": "Další pravopisná nebo gramatická chyba", "previous_issue": "Předchozí pravopisná nebo gramatická chyba" } + }, + "pwaWindows": { + "open_in_window": "Otevřít v novém okně", + "save_all_to_folder": "Uložit vše do složky…", + "saved_all": "{count, plural, one {# příloha uložena do {folder}.} few {# přílohy uloženy do {folder}.} other {# příloh uloženo do {folder}.}}", + "saved_partial": "Uloženo {saved} z {total} příloh do {folder}.", + "save_failed": "Přílohy se nepodařilo uložit do {folder}.", + "popup_blocked": "Prohlížeč nové okno zablokoval. Povolte vyskakovací okna pro tento web a zkuste to znovu.", + "opening": "Otevírání…", + "session_ended_title": "V tomto okně jste odhlášeni", + "session_ended_body": "Otevřete aplikaci, přihlaste se a potom toto okno otevřete znovu.", + "open_app": "Otevřít aplikaci", + "close_window": "Zavřít okno", + "message_gone_title": "Tato zpráva už tu není", + "message_gone_body": "Byla smazána nebo přesunuta do jiného účtu.", + "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á." } } diff --git a/locales/da/common.json b/locales/da/common.json index 7ecfa2e82..490f7ff2b 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Afvis noten om {subject}", "banner": "Ændringer, der ikke blev gennemført: {count}", "toast": "Ændringer lavet offline, der ikke blev gennemført: {count}. Udbakken viser hvilke.", - "storage_failed": "Denne enhed har ikke mere plads til offlinemail. Frigør plads, eller behold mindre i indstillingerne." + "storage_failed": "Denne enhed har ikke mere plads til offlinemail. Frigør plads, eller behold mindre i indstillingerne.", + "signout_changes_title": "Log ud med ændringer, der ikke er gemt?", + "signout_changes": "Ændringer, der endnu ikke er på serveren: {count}. Når du logger ud, slettes de.", + "store_unavailable": "Offlinemail på denne enhed kan ikke åbnes lige nu. Ikke-sendt mail bliver her, og appen bliver ved med at prøve.", + "store_reset": "Offlinemail på denne enhed kunne ikke læses og hentes derfor igen. Det, der ikke var sendt, er væk.", + "legacy_stranded": "Denne browser kan ikke åbne ikke-sendt mail fra en tidligere version. En opdatering af browseren kan hente den tilbage.", + "store_private": "Private vinduer kan ikke gemme mail til brug offline.", + "server_not_answering": "Mailserveren svarer ikke. Prøv igen om lidt." }, "offlineStorage": { "keep_description": "Beholder din mail, kalender, dine kontakter og de filer, du vælger, krypteret i denne browser. Offline kan du læse, markere og flytte mail, og det, du skriver, venter i udbakken. Når du logger ud, fjernes det hele.", @@ -6550,5 +6557,32 @@ "next_issue": "Næste stave- eller grammatikfejl", "previous_issue": "Forrige stave- eller grammatikfejl" } + }, + "pwaWindows": { + "open_in_window": "Åbn i nyt vindue", + "save_all_to_folder": "Gem alle i en mappe…", + "saved_all": "{count, plural, one {# vedhæftet fil gemt i {folder}.} other {# vedhæftede filer gemt i {folder}.}}", + "saved_partial": "{saved} af {total} vedhæftede filer gemt i {folder}.", + "save_failed": "De vedhæftede filer kunne ikke gemmes i {folder}.", + "popup_blocked": "Browseren blokerede det nye vindue. Tillad pop op-vinduer for dette websted, og prøv igen.", + "opening": "Åbner…", + "session_ended_title": "Du er logget ud i dette vindue", + "session_ended_body": "Åbn appen og log ind, og åbn derefter dette vindue igen.", + "open_app": "Åbn appen", + "close_window": "Luk vindue", + "message_gone_title": "Denne besked er væk", + "message_gone_body": "Den er slettet eller flyttet til en anden konto.", + "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." } } diff --git a/locales/de/common.json b/locales/de/common.json index 001c5ba3b..0e20e1b8b 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Hinweis zu {subject} ausblenden", "banner": "Nicht übernommene Änderungen: {count}", "toast": "Offline gemachte Änderungen wurden nicht übernommen: {count}. Welche, steht im Postausgang.", - "storage_failed": "Auf diesem Gerät ist kein Platz mehr für Offline-E-Mails. Geben Sie Speicher frei oder behalten Sie in den Einstellungen weniger." + "storage_failed": "Auf diesem Gerät ist kein Platz mehr für Offline-E-Mails. Geben Sie Speicher frei oder behalten Sie in den Einstellungen weniger.", + "signout_changes_title": "Mit nicht gespeicherten Änderungen abmelden?", + "signout_changes": "Änderungen, die noch nicht auf dem Server sind: {count}. Beim Abmelden werden sie gelöscht.", + "store_unavailable": "Die Offline-E-Mails auf diesem Gerät lassen sich gerade nicht öffnen. Nicht gesendete E-Mails bleiben hier, und die App versucht es weiter.", + "store_reset": "Die Offline-E-Mails auf diesem Gerät konnten nicht gelesen werden und werden neu heruntergeladen. Was hier noch nicht gesendet war, ist verloren.", + "legacy_stranded": "Dieser Browser kann nicht gesendete E-Mails einer früheren Version nicht öffnen. Ein Browser-Update kann sie zurückholen.", + "store_private": "Private Fenster können keine E-Mails für die Offline-Nutzung behalten.", + "server_not_answering": "Der Mailserver antwortet nicht. Versuchen Sie es gleich noch einmal." }, "offlineStorage": { "keep_description": "Behält Ihre E-Mails, Ihren Kalender, Ihre Kontakte und ausgewählte Dateien verschlüsselt in diesem Browser. Offline können Sie E-Mails lesen, markieren und verschieben, und was Sie schreiben, wartet im Postausgang. Beim Abmelden wird alles entfernt.", @@ -6550,5 +6557,32 @@ "next_issue": "Nächster Rechtschreib- oder Grammatikfehler", "previous_issue": "Vorheriger Rechtschreib- oder Grammatikfehler" } + }, + "pwaWindows": { + "open_in_window": "In neuem Fenster öffnen", + "save_all_to_folder": "Alle in einem Ordner speichern…", + "saved_all": "{count, plural, one {# Anhang in {folder} gespeichert.} other {# Anhänge in {folder} gespeichert.}}", + "saved_partial": "{saved} von {total} Anhängen in {folder} gespeichert.", + "save_failed": "Die Anhänge konnten nicht in {folder} gespeichert werden.", + "popup_blocked": "Der Browser hat das neue Fenster blockiert. Erlauben Sie Pop-ups für diese Seite und versuchen Sie es noch einmal.", + "opening": "Wird geöffnet…", + "session_ended_title": "Sie sind in diesem Fenster abgemeldet", + "session_ended_body": "Öffnen Sie die App, melden Sie sich an und öffnen Sie dieses Fenster dann noch einmal.", + "open_app": "App öffnen", + "close_window": "Fenster schließen", + "message_gone_title": "Diese Nachricht ist nicht mehr da", + "message_gone_body": "Sie wurde gelöscht oder in ein anderes Konto verschoben.", + "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." } } diff --git a/locales/en/common.json b/locales/en/common.json index 2093b56e6..46350c150 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Dismiss the note about {subject}", "banner": "Changes that didn't go through: {count}", "toast": "Changes you made offline weren't applied: {count}. The outbox shows which.", - "storage_failed": "This device has no room left for offline mail. Free up space, or keep less in Settings." + "storage_failed": "This device has no room left for offline mail. Free up space, or keep less in Settings.", + "signout_changes_title": "Sign out with unsaved changes?", + "signout_changes": "Changes not yet on the server: {count}. Signing out deletes them.", + "store_unavailable": "Offline mail on this device can't be opened right now. Unsent mail stays here, and the app keeps trying.", + "store_reset": "Offline mail on this device couldn't be read, so it's being downloaded again. Anything unsent that was kept here is gone.", + "legacy_stranded": "This browser can't open unsent mail kept by an earlier version. Updating the browser may bring it back.", + "store_private": "Private windows can't keep mail for offline use.", + "server_not_answering": "The mail server isn't answering. Try again in a moment." }, "offlineStorage": { "keep_description": "Keeps your mail, calendar, contacts and chosen files encrypted in this browser. Offline you can read, flag and move mail, and what you write waits in the outbox. Signing out removes all of it.", @@ -6550,5 +6557,32 @@ "next_issue": "Next spelling or grammar issue", "previous_issue": "Previous spelling or grammar issue" } + }, + "pwaWindows": { + "open_in_window": "Open in new window", + "save_all_to_folder": "Save all to a folder…", + "saved_all": "{count, plural, one {Saved # attachment to {folder}.} other {Saved # attachments to {folder}.}}", + "saved_partial": "Saved {saved} of {total} attachments to {folder}.", + "save_failed": "Could not save the attachments to {folder}.", + "popup_blocked": "The browser blocked the new window. Allow pop-ups for this site and try again.", + "opening": "Opening…", + "session_ended_title": "You are signed out in this window", + "session_ended_body": "Open the app and sign in, then open this window again.", + "open_app": "Open the app", + "close_window": "Close window", + "message_gone_title": "This message is gone", + "message_gone_body": "It was deleted or moved to another account.", + "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." } } diff --git a/locales/es/common.json b/locales/es/common.json index c2246b552..10aad8120 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Descartar la nota sobre {subject}", "banner": "Cambios no aplicados: {count}", "toast": "Cambios hechos sin conexión que no se aplicaron: {count}. La bandeja de salida indica cuáles.", - "storage_failed": "Este dispositivo no tiene espacio para el correo sin conexión. Libera espacio o guarda menos en Ajustes." + "storage_failed": "Este dispositivo no tiene espacio para el correo sin conexión. Libera espacio o guarda menos en Ajustes.", + "signout_changes_title": "¿Cerrar sesión con cambios sin guardar?", + "signout_changes": "Cambios que aún no están en el servidor: {count}. Al cerrar sesión se eliminan.", + "store_unavailable": "El correo sin conexión de este dispositivo no se puede abrir ahora. El correo sin enviar sigue aquí y la aplicación sigue intentándolo.", + "store_reset": "No se pudo leer el correo sin conexión de este dispositivo, así que se está descargando de nuevo. Lo que estaba sin enviar se ha perdido.", + "legacy_stranded": "Este navegador no puede abrir el correo sin enviar guardado por una versión anterior. Actualizar el navegador puede recuperarlo.", + "store_private": "Las ventanas privadas no pueden guardar correo para usarlo sin conexión.", + "server_not_answering": "El servidor de correo no responde. Inténtalo de nuevo en un momento." }, "offlineStorage": { "keep_description": "Guarda tu correo, tu calendario, tus contactos y los archivos que elijas, cifrados en este navegador. Sin conexión puedes leer, marcar y mover correo, y lo que escribas espera en la bandeja de salida. Al cerrar sesión se borra todo.", @@ -6550,5 +6557,32 @@ "next_issue": "Siguiente error de ortografía o gramática", "previous_issue": "Anterior error de ortografía o gramática" } + }, + "pwaWindows": { + "open_in_window": "Abrir en una ventana nueva", + "save_all_to_folder": "Guardar todo en una carpeta…", + "saved_all": "{count, plural, one {Se guardó # adjunto en {folder}.} other {Se guardaron # adjuntos en {folder}.}}", + "saved_partial": "Se guardaron {saved} de {total} adjuntos en {folder}.", + "save_failed": "No se pudieron guardar los adjuntos en {folder}.", + "popup_blocked": "El navegador bloqueó la ventana nueva. Permite las ventanas emergentes para este sitio e inténtalo de nuevo.", + "opening": "Abriendo…", + "session_ended_title": "Has cerrado sesión en esta ventana", + "session_ended_body": "Abre la aplicación e inicia sesión, y luego vuelve a abrir esta ventana.", + "open_app": "Abrir la aplicación", + "close_window": "Cerrar ventana", + "message_gone_title": "Este mensaje ya no está", + "message_gone_body": "Se eliminó o se movió a otra cuenta.", + "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." } } diff --git a/locales/et/common.json b/locales/et/common.json index 289a223d8..6ec985068 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Sulge märkus teemal {subject}", "banner": "Rakendamata muudatused: {count}", "toast": "Võrguühenduseta tehtud rakendamata muudatused: {count}. Väljundkaust näitab, millised.", - "storage_failed": "Selles seadmes pole enam ruumi võrguühenduseta kirjade jaoks. Vabastage ruumi või hoidke seadetes vähem." + "storage_failed": "Selles seadmes pole enam ruumi võrguühenduseta kirjade jaoks. Vabastage ruumi või hoidke seadetes vähem.", + "signout_changes_title": "Kas logida välja salvestamata muudatustega?", + "signout_changes": "Muudatused, mida serveris veel pole: {count}. Väljalogimine kustutab need.", + "store_unavailable": "Selle seadme võrguühenduseta kirju ei saa praegu avada. Saatmata kirjad jäävad siia ja rakendus proovib edasi.", + "store_reset": "Selle seadme võrguühenduseta kirju ei õnnestunud lugeda, seega laaditakse need uuesti alla. Mis oli saatmata, on kadunud.", + "legacy_stranded": "See brauser ei saa avada varasema versiooni hoitud saatmata kirju. Brauseri uuendamine võib need tagasi tuua.", + "store_private": "Privaatsed aknad ei saa kirju võrguühenduseta kasutamiseks hoida.", + "server_not_answering": "Meiliserver ei vasta. Proovige kohe uuesti." }, "offlineStorage": { "keep_description": "Hoiab sinu kirju, kalendrit, kontakte ja valitud faile krüptitult selles brauseris. Võrguta saad kirju lugeda, märgistada ja teisaldada ning see, mida kirjutad, ootab väljuvate kirjade kastis. Välja logides eemaldatakse kõik.", @@ -6550,5 +6557,32 @@ "next_issue": "Järgmine õigekirja- või grammatikaviga", "previous_issue": "Eelmine õigekirja- või grammatikaviga" } + }, + "pwaWindows": { + "open_in_window": "Ava uues aknas", + "save_all_to_folder": "Salvesta kõik kausta…", + "saved_all": "{count, plural, one {# manus salvestati kausta {folder}.} other {# manust salvestati kausta {folder}.}}", + "saved_partial": "{total} manusest {saved} salvestati kausta {folder}.", + "save_failed": "Manuseid ei saanud kausta {folder} salvestada.", + "popup_blocked": "Brauser blokeeris uue akna. Luba selle saidi hüpikaknad ja proovi uuesti.", + "opening": "Avamine…", + "session_ended_title": "Oled selles aknas välja logitud", + "session_ended_body": "Ava rakendus ja logi sisse, seejärel ava see aken uuesti.", + "open_app": "Ava rakendus", + "close_window": "Sulge aken", + "message_gone_title": "Seda kirja enam ei ole", + "message_gone_body": "See kustutati või teisaldati teisele kontole.", + "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." } } diff --git a/locales/fa/common.json b/locales/fa/common.json index 0e1056d21..69bcba248 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "بستن یادداشت درباره {subject}", "banner": "تغییراتی که اعمال نشدند: {count}", "toast": "تغییرات آفلاین که اعمال نشدند: {count}. صندوق خروجی نشان می‌دهد کدام‌ها.", - "storage_failed": "در این دستگاه جایی برای ایمیل آفلاین نمانده است. فضا را آزاد کنید یا در تنظیمات مقدار کمتری نگه دارید." + "storage_failed": "در این دستگاه جایی برای ایمیل آفلاین نمانده است. فضا را آزاد کنید یا در تنظیمات مقدار کمتری نگه دارید.", + "signout_changes_title": "با تغییرات ذخیره‌نشده از حساب خارج می‌شوید؟", + "signout_changes": "تغییراتی که هنوز روی سرور نیستند: {count}. خروج از حساب آن‌ها را حذف می‌کند.", + "store_unavailable": "ایمیل آفلاین این دستگاه اکنون باز نمی‌شود. ایمیل ارسال‌نشده اینجا می‌ماند و برنامه همچنان تلاش می‌کند.", + "store_reset": "ایمیل آفلاین این دستگاه خوانده نشد و دوباره بارگیری می‌شود. آنچه ارسال نشده بود از بین رفته است.", + "legacy_stranded": "این مرورگر نمی‌تواند ایمیل ارسال‌نشدهٔ نگه‌داشته‌شده از نسخهٔ قبلی را باز کند. به‌روزرسانی مرورگر ممکن است آن را برگرداند.", + "store_private": "پنجره‌های خصوصی نمی‌توانند ایمیل را برای استفادهٔ آفلاین نگه دارند.", + "server_not_answering": "سرور ایمیل پاسخ نمی‌دهد. کمی بعد دوباره امتحان کنید." }, "offlineStorage": { "keep_description": "ایمیل‌ها، تقویم، مخاطبان و فایل‌هایی را که انتخاب می‌کنید به‌صورت رمزگذاری‌شده در این مرورگر نگه می‌دارد. در حالت آفلاین می‌توانید ایمیل‌ها را بخوانید، پرچم بزنید و جابه‌جا کنید، و آنچه می‌نویسید در صندوق خروجی منتظر می‌ماند. با خروج از حساب همه پاک می‌شود.", @@ -6550,5 +6557,32 @@ "next_issue": "خطای املایی یا دستوری بعدی", "previous_issue": "خطای املایی یا دستوری قبلی" } + }, + "pwaWindows": { + "open_in_window": "باز کردن در پنجرهٔ جدید", + "save_all_to_folder": "ذخیرهٔ همه در یک پوشه…", + "saved_all": "{count, plural, one {# پیوست در {folder} ذخیره شد.} other {# پیوست در {folder} ذخیره شد.}}", + "saved_partial": "{saved} از {total} پیوست در {folder} ذخیره شد.", + "save_failed": "پیوست‌ها در {folder} ذخیره نشدند.", + "popup_blocked": "مرورگر پنجرهٔ جدید را مسدود کرد. پنجره‌های بازشو را برای این سایت مجاز کنید و دوباره امتحان کنید.", + "opening": "در حال باز کردن…", + "session_ended_title": "در این پنجره از حساب خارج شده‌اید", + "session_ended_body": "برنامه را باز کنید و وارد شوید، سپس این پنجره را دوباره باز کنید.", + "open_app": "باز کردن برنامه", + "close_window": "بستن پنجره", + "message_gone_title": "این پیام دیگر وجود ندارد", + "message_gone_body": "حذف شده یا به حساب دیگری منتقل شده است.", + "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": "این نشانی سرور معتبر نیست." } } diff --git a/locales/fr/common.json b/locales/fr/common.json index f1e6d1484..4fa2ab5f0 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Ignorer la note sur {subject}", "banner": "Modifications non appliquées : {count}", "toast": "Des modifications faites hors ligne n'ont pas été appliquées : {count}. La boîte d'envoi indique lesquelles.", - "storage_failed": "Cet appareil n'a plus de place pour le courrier hors ligne. Libérez de l'espace ou conservez moins dans les paramètres." + "storage_failed": "Cet appareil n'a plus de place pour le courrier hors ligne. Libérez de l'espace ou conservez moins dans les paramètres.", + "signout_changes_title": "Se déconnecter avec des modifications non enregistrées ?", + "signout_changes": "Modifications pas encore sur le serveur : {count}. La déconnexion les supprime.", + "store_unavailable": "Le courrier hors ligne de cet appareil ne peut pas être ouvert pour le moment. Les messages non envoyés restent ici et l'application continue d'essayer.", + "store_reset": "Le courrier hors ligne de cet appareil n'a pas pu être lu : il est de nouveau téléchargé. Ce qui n'était pas envoyé est perdu.", + "legacy_stranded": "Ce navigateur ne peut pas ouvrir les messages non envoyés conservés par une version précédente. Le mettre à jour peut les récupérer.", + "store_private": "Les fenêtres privées ne peuvent pas conserver le courrier pour une utilisation hors ligne.", + "server_not_answering": "Le serveur de messagerie ne répond pas. Réessayez dans un instant." }, "offlineStorage": { "keep_description": "Garde vos e-mails, votre agenda, vos contacts et les fichiers que vous choisissez, chiffrés dans ce navigateur. Hors ligne, vous pouvez lire, signaler et déplacer des e-mails, et ce que vous écrivez attend dans la boîte d'envoi. La déconnexion supprime tout.", @@ -6550,5 +6557,32 @@ "next_issue": "Faute d'orthographe ou de grammaire suivante", "previous_issue": "Faute d'orthographe ou de grammaire précédente" } + }, + "pwaWindows": { + "open_in_window": "Ouvrir dans une nouvelle fenêtre", + "save_all_to_folder": "Tout enregistrer dans un dossier…", + "saved_all": "{count, plural, one {# pièce jointe enregistrée dans {folder}.} other {# pièces jointes enregistrées dans {folder}.}}", + "saved_partial": "{saved} pièces jointes sur {total} enregistrées dans {folder}.", + "save_failed": "Impossible d’enregistrer les pièces jointes dans {folder}.", + "popup_blocked": "Le navigateur a bloqué la nouvelle fenêtre. Autorisez les fenêtres pop-up pour ce site et réessayez.", + "opening": "Ouverture…", + "session_ended_title": "Vous êtes déconnecté dans cette fenêtre", + "session_ended_body": "Ouvrez l’application et connectez-vous, puis rouvrez cette fenêtre.", + "open_app": "Ouvrir l’application", + "close_window": "Fermer la fenêtre", + "message_gone_title": "Ce message n’existe plus", + "message_gone_body": "Il a été supprimé ou déplacé vers un autre compte.", + "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." } } diff --git a/locales/he/common.json b/locales/he/common.json index 9ff456a21..9a404b7ec 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5755,7 +5755,14 @@ "dismiss_label": "סגירת ההערה על {subject}", "banner": "שינויים שלא הוחלו: {count}", "toast": "שינויים במצב לא מקוון שלא הוחלו: {count}. דואר יוצא מראה אילו.", - "storage_failed": "אין יותר מקום במכשיר הזה לדואר לא מקוון. יש לפנות מקום או לשמור פחות בהגדרות." + "storage_failed": "אין יותר מקום במכשיר הזה לדואר לא מקוון. יש לפנות מקום או לשמור פחות בהגדרות.", + "signout_changes_title": "לצאת מהחשבון עם שינויים שלא נשמרו?", + "signout_changes": "שינויים שעדיין לא בשרת: {count}. היציאה מהחשבון מוחקת אותם.", + "store_unavailable": "אי אפשר לפתוח כרגע את הדואר הלא מקוון במכשיר הזה. דואר שלא נשלח נשאר כאן, והאפליקציה ממשיכה לנסות.", + "store_reset": "לא ניתן היה לקרוא את הדואר הלא מקוון במכשיר הזה, ולכן הוא יורד מחדש. מה שלא נשלח אבד.", + "legacy_stranded": "הדפדפן הזה לא יכול לפתוח דואר שלא נשלח שנשמר בגרסה קודמת. עדכון הדפדפן עשוי להחזיר אותו.", + "store_private": "חלונות פרטיים לא יכולים לשמור דואר לשימוש לא מקוון.", + "server_not_answering": "שרת הדואר לא עונה. אפשר לנסות שוב בעוד רגע." }, "offlineStorage": { "keep_description": "שומר את הדואר, היומן, אנשי הקשר והקבצים שבחרת, מוצפנים בדפדפן הזה. במצב לא מקוון אפשר לקרוא, לסמן ולהעביר דואר, ומה שכותבים ממתין בתיבת הדואר היוצא. התנתקות מוחקת הכול.", @@ -6551,5 +6558,32 @@ "next_issue": "שגיאת האיות או הדקדוק הבאה", "previous_issue": "שגיאת האיות או הדקדוק הקודמת" } + }, + "pwaWindows": { + "open_in_window": "פתיחה בחלון חדש", + "save_all_to_folder": "שמירת הכול בתיקייה…", + "saved_all": "{count, plural, one {קובץ מצורף אחד נשמר ב-{folder}.} two {# קבצים מצורפים נשמרו ב-{folder}.} other {# קבצים מצורפים נשמרו ב-{folder}.}}", + "saved_partial": "{saved} מתוך {total} קבצים מצורפים נשמרו ב-{folder}.", + "save_failed": "לא ניתן לשמור את הקבצים המצורפים ב-{folder}.", + "popup_blocked": "הדפדפן חסם את החלון החדש. יש לאפשר חלונות קופצים לאתר הזה ולנסות שוב.", + "opening": "בפתיחה…", + "session_ended_title": "התנתקת בחלון הזה", + "session_ended_body": "יש לפתוח את האפליקציה ולהתחבר, ואז לפתוח את החלון הזה שוב.", + "open_app": "פתיחת האפליקציה", + "close_window": "סגירת החלון", + "message_gone_title": "ההודעה הזו כבר לא קיימת", + "message_gone_body": "היא נמחקה או הועברה לחשבון אחר.", + "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": "כתובת השרת הזו לא תקינה." } } diff --git a/locales/hu/common.json b/locales/hu/common.json index cee547999..799f05ae3 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "A(z) {subject} megjegyzésének bezárása", "banner": "Nem alkalmazott módosítások: {count}", "toast": "Offline végzett, nem alkalmazott módosítások: {count}. A postázandók mappa mutatja, melyek.", - "storage_failed": "Ezen az eszközön nincs több hely az offline leveleknek. Szabadítson fel helyet, vagy tartson meg kevesebbet a beállításokban." + "storage_failed": "Ezen az eszközön nincs több hely az offline leveleknek. Szabadítson fel helyet, vagy tartson meg kevesebbet a beállításokban.", + "signout_changes_title": "Kijelentkezik a nem mentett módosításokkal?", + "signout_changes": "Még nem a szerveren lévő módosítások: {count}. A kijelentkezés törli őket.", + "store_unavailable": "Az eszközön lévő offline levelek most nem nyithatók meg. Az el nem küldött levelek itt maradnak, az alkalmazás pedig tovább próbálkozik.", + "store_reset": "Az eszközön lévő offline leveleket nem sikerült beolvasni, ezért újra letöltődnek. Ami nem lett elküldve, elveszett.", + "legacy_stranded": "Ez a böngésző nem tudja megnyitni egy korábbi verzió által megőrzött, el nem küldött leveleket. A böngésző frissítése visszahozhatja őket.", + "store_private": "A privát ablakok nem tudnak leveleket megőrizni offline használatra.", + "server_not_answering": "A levelezőszerver nem válaszol. Próbálja újra egy pillanat múlva." }, "offlineStorage": { "keep_description": "Megtartja a leveleit, a naptárát, a névjegyeit és a kiválasztott fájlokat, titkosítva ebben a böngészőben. Offline olvashat, megjelölhet és áthelyezhet leveleket, a megírt levelek pedig a postázandók között várnak. Kijelentkezéskor minden törlődik.", @@ -6550,5 +6557,32 @@ "next_issue": "Következő helyesírási vagy nyelvtani hiba", "previous_issue": "Előző helyesírási vagy nyelvtani hiba" } + }, + "pwaWindows": { + "open_in_window": "Megnyitás új ablakban", + "save_all_to_folder": "Összes mentése mappába…", + "saved_all": "{count, plural, one {# melléklet mentve ide: {folder}.} other {# melléklet mentve ide: {folder}.}}", + "saved_partial": "{total} mellékletből {saved} mentve ide: {folder}.", + "save_failed": "A mellékleteket nem sikerült ide menteni: {folder}.", + "popup_blocked": "A böngésző letiltotta az új ablakot. Engedélyezze az előugró ablakokat ennél a webhelynél, majd próbálja újra.", + "opening": "Megnyitás…", + "session_ended_title": "Ebben az ablakban ki van jelentkezve", + "session_ended_body": "Nyissa meg az alkalmazást, jelentkezzen be, majd nyissa meg újra ezt az ablakot.", + "open_app": "Alkalmazás megnyitása", + "close_window": "Ablak bezárása", + "message_gone_title": "Ez az üzenet már nincs meg", + "message_gone_body": "Törölték, vagy másik fiókba helyezték át.", + "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." } } diff --git a/locales/it/common.json b/locales/it/common.json index a5110bfd8..509b08082 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Ignora la nota su {subject}", "banner": "Modifiche non applicate: {count}", "toast": "Modifiche fatte offline non applicate: {count}. La posta in uscita indica quali.", - "storage_failed": "Questo dispositivo non ha più spazio per la posta offline. Libera spazio o conservane meno nelle impostazioni." + "storage_failed": "Questo dispositivo non ha più spazio per la posta offline. Libera spazio o conservane meno nelle impostazioni.", + "signout_changes_title": "Uscire con modifiche non salvate?", + "signout_changes": "Modifiche non ancora sul server: {count}. Uscendo vengono eliminate.", + "store_unavailable": "La posta offline di questo dispositivo non si può aprire ora. La posta non inviata resta qui e l'app continua a provare.", + "store_reset": "Non è stato possibile leggere la posta offline di questo dispositivo, quindi viene scaricata di nuovo. Ciò che non era stato inviato è perso.", + "legacy_stranded": "Questo browser non può aprire la posta non inviata conservata da una versione precedente. Aggiornare il browser può recuperarla.", + "store_private": "Le finestre private non possono conservare la posta per l'uso offline.", + "server_not_answering": "Il server di posta non risponde. Riprova tra un momento." }, "offlineStorage": { "keep_description": "Conserva la tua posta, il calendario, i contatti e i file che scegli, cifrati in questo browser. Offline puoi leggere, contrassegnare e spostare la posta, e ciò che scrivi aspetta nella posta in uscita. Uscendo dall'account viene rimosso tutto.", @@ -6550,5 +6557,32 @@ "next_issue": "Errore di ortografia o grammatica successivo", "previous_issue": "Errore di ortografia o grammatica precedente" } + }, + "pwaWindows": { + "open_in_window": "Apri in una nuova finestra", + "save_all_to_folder": "Salva tutto in una cartella…", + "saved_all": "{count, plural, one {# allegato salvato in {folder}.} other {# allegati salvati in {folder}.}}", + "saved_partial": "{saved} allegati su {total} salvati in {folder}.", + "save_failed": "Impossibile salvare gli allegati in {folder}.", + "popup_blocked": "Il browser ha bloccato la nuova finestra. Consenti i pop-up per questo sito e riprova.", + "opening": "Apertura…", + "session_ended_title": "Hai effettuato la disconnessione in questa finestra", + "session_ended_body": "Apri l’app e accedi, poi riapri questa finestra.", + "open_app": "Apri l’app", + "close_window": "Chiudi finestra", + "message_gone_title": "Questo messaggio non c’è più", + "message_gone_body": "È stato eliminato o spostato in un altro account.", + "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." } } diff --git a/locales/ja/common.json b/locales/ja/common.json index 87afe9a32..69a3faa56 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "{subject} のメモを閉じる", "banner": "適用されなかった変更: {count}", "toast": "オフライン中の変更で適用されなかったもの: {count} 件。送信トレイで確認できます。", - "storage_failed": "このデバイスにはオフラインメール用の空き容量がありません。空き容量を増やすか、設定で保存する量を減らしてください。" + "storage_failed": "このデバイスにはオフラインメール用の空き容量がありません。空き容量を増やすか、設定で保存する量を減らしてください。", + "signout_changes_title": "未保存の変更があるままサインアウトしますか?", + "signout_changes": "サーバーにまだ届いていない変更: {count} 件。サインアウトすると削除されます。", + "store_unavailable": "このデバイスのオフラインメールを今は開けません。未送信のメールはここに残り、アプリは再試行を続けます。", + "store_reset": "このデバイスのオフラインメールを読み取れなかったため、再ダウンロードしています。未送信だったものは失われました。", + "legacy_stranded": "このブラウザーでは、以前のバージョンが保存した未送信メールを開けません。ブラウザーを更新すると戻る場合があります。", + "store_private": "プライベートウィンドウではオフライン用にメールを保存できません。", + "server_not_answering": "メールサーバーが応答していません。少ししてからもう一度お試しください。" }, "offlineStorage": { "keep_description": "メール、カレンダー、連絡先、選んだファイルを暗号化してこのブラウザーに保存します。オフラインでもメールを読む、フラグを付ける、移動することができ、書いたメールは送信トレイで待機します。サインアウトするとすべて削除されます。", @@ -6550,5 +6557,32 @@ "next_issue": "次のスペルまたは文法の問題", "previous_issue": "前のスペルまたは文法の問題" } + }, + "pwaWindows": { + "open_in_window": "新しいウィンドウで開く", + "save_all_to_folder": "すべてをフォルダーに保存…", + "saved_all": "{count, plural, other {# 件の添付ファイルを {folder} に保存しました。}}", + "saved_partial": "{total} 件中 {saved} 件の添付ファイルを {folder} に保存しました。", + "save_failed": "添付ファイルを {folder} に保存できませんでした。", + "popup_blocked": "ブラウザーが新しいウィンドウをブロックしました。このサイトのポップアップを許可して、もう一度お試しください。", + "opening": "開いています…", + "session_ended_title": "このウィンドウではサインアウトしています", + "session_ended_body": "アプリを開いてサインインしてから、このウィンドウをもう一度開いてください。", + "open_app": "アプリを開く", + "close_window": "ウィンドウを閉じる", + "message_gone_title": "このメッセージはもうありません", + "message_gone_body": "削除されたか、別のアカウントに移動されました。", + "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": "このサーバーアドレスは正しくありません。" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index 8a406007c..130320758 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "{subject} 관련 메모 닫기", "banner": "적용되지 않은 변경 사항: {count}", "toast": "오프라인에서 변경했지만 적용되지 않은 항목: {count}개. 보낼 편지함에서 확인할 수 있습니다.", - "storage_failed": "이 기기에 오프라인 메일을 저장할 공간이 없습니다. 공간을 확보하거나 설정에서 보관량을 줄이세요." + "storage_failed": "이 기기에 오프라인 메일을 저장할 공간이 없습니다. 공간을 확보하거나 설정에서 보관량을 줄이세요.", + "signout_changes_title": "저장하지 않은 변경 사항이 있는 채로 로그아웃할까요?", + "signout_changes": "아직 서버에 없는 변경 사항: {count}개. 로그아웃하면 삭제됩니다.", + "store_unavailable": "지금은 이 기기의 오프라인 메일을 열 수 없습니다. 보내지 않은 메일은 여기에 남아 있고 앱이 계속 시도합니다.", + "store_reset": "이 기기의 오프라인 메일을 읽을 수 없어 다시 내려받는 중입니다. 보내지 않은 내용은 사라졌습니다.", + "legacy_stranded": "이 브라우저에서는 이전 버전이 보관한 보내지 않은 메일을 열 수 없습니다. 브라우저를 업데이트하면 되찾을 수 있습니다.", + "store_private": "개인 정보 보호 창에서는 오프라인용 메일을 보관할 수 없습니다.", + "server_not_answering": "메일 서버가 응답하지 않습니다. 잠시 후 다시 시도하세요." }, "offlineStorage": { "keep_description": "메일, 캘린더, 연락처, 선택한 파일을 이 브라우저에 암호화해 보관합니다. 오프라인에서도 메일을 읽고, 플래그를 지정하고, 이동할 수 있으며, 작성한 메일은 보낼편지함에서 대기합니다. 로그아웃하면 모두 삭제됩니다.", @@ -6550,5 +6557,32 @@ "next_issue": "다음 맞춤법 또는 문법 문제", "previous_issue": "이전 맞춤법 또는 문법 문제" } + }, + "pwaWindows": { + "open_in_window": "새 창에서 열기", + "save_all_to_folder": "모두 폴더에 저장…", + "saved_all": "{count, plural, other {첨부 파일 #개를 {folder}에 저장했습니다.}}", + "saved_partial": "첨부 파일 {total}개 중 {saved}개를 {folder}에 저장했습니다.", + "save_failed": "첨부 파일을 {folder}에 저장할 수 없습니다.", + "popup_blocked": "브라우저가 새 창을 차단했습니다. 이 사이트의 팝업을 허용한 후 다시 시도하세요.", + "opening": "여는 중…", + "session_ended_title": "이 창에서 로그아웃되었습니다", + "session_ended_body": "앱을 열어 로그인한 다음 이 창을 다시 여세요.", + "open_app": "앱 열기", + "close_window": "창 닫기", + "message_gone_title": "이 메시지는 더 이상 없습니다", + "message_gone_body": "삭제되었거나 다른 계정으로 이동되었습니다.", + "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": "이 서버 주소는 올바르지 않습니다." } } diff --git a/locales/lv/common.json b/locales/lv/common.json index 454eaa1bd..d19629112 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Aizvērt piezīmi par {subject}", "banner": "Nepiemērotās izmaiņas: {count}", "toast": "Bezsaistē veiktās nepiemērotās izmaiņas: {count}. Izsūtne parāda, kuras.", - "storage_failed": "Šajā ierīcē vairs nav vietas bezsaistes pastam. Atbrīvojiet vietu vai iestatījumos glabājiet mazāk." + "storage_failed": "Šajā ierīcē vairs nav vietas bezsaistes pastam. Atbrīvojiet vietu vai iestatījumos glabājiet mazāk.", + "signout_changes_title": "Vai izrakstīties ar nesaglabātām izmaiņām?", + "signout_changes": "Izmaiņas, kas vēl nav serverī: {count}. Izrakstoties tās tiks izdzēstas.", + "store_unavailable": "Bezsaistes pastu šajā ierīcē pašlaik nevar atvērt. Nenosūtītais pasts paliek šeit, un lietotne turpina mēģināt.", + "store_reset": "Bezsaistes pastu šajā ierīcē neizdevās nolasīt, tāpēc tas tiek lejupielādēts vēlreiz. Tas, kas nebija nosūtīts, ir zudis.", + "legacy_stranded": "Šī pārlūkprogramma nevar atvērt iepriekšējās versijas saglabāto nenosūtīto pastu. Pārlūkprogrammas atjaunināšana var to atgūt.", + "store_private": "Privātie logi nevar glabāt pastu lietošanai bezsaistē.", + "server_not_answering": "Pasta serveris neatbild. Mēģiniet vēlreiz pēc brīža." }, "offlineStorage": { "keep_description": "Glabā jūsu e-pastu, kalendāru, kontaktpersonas un izvēlētos failus šifrētus šajā pārlūkā. Bezsaistē varat lasīt, atzīmēt un pārvietot vēstules, un tas, ko rakstāt, gaida izsūtnē. Izrakstoties viss tiek noņemts.", @@ -6550,5 +6557,32 @@ "next_issue": "Nākamā pareizrakstības vai gramatikas kļūda", "previous_issue": "Iepriekšējā pareizrakstības vai gramatikas kļūda" } + }, + "pwaWindows": { + "open_in_window": "Atvērt jaunā logā", + "save_all_to_folder": "Saglabāt visu mapē…", + "saved_all": "{count, plural, zero {# pielikumi saglabāti mapē {folder}.} one {# pielikums saglabāts mapē {folder}.} other {# pielikumi saglabāti mapē {folder}.}}", + "saved_partial": "Saglabāti {saved} no {total} pielikumiem mapē {folder}.", + "save_failed": "Pielikumus neizdevās saglabāt mapē {folder}.", + "popup_blocked": "Pārlūks bloķēja jauno logu. Atļaujiet uznirstošos logus šai vietnei un mēģiniet vēlreiz.", + "opening": "Notiek atvēršana…", + "session_ended_title": "Šajā logā esat izrakstījies", + "session_ended_body": "Atveriet lietotni un pierakstieties, pēc tam atveriet šo logu vēlreiz.", + "open_app": "Atvērt lietotni", + "close_window": "Aizvērt logu", + "message_gone_title": "Šī ziņojuma vairs nav", + "message_gone_body": "Tas tika dzēsts vai pārvietots uz citu kontu.", + "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." } } diff --git a/locales/mn/common.json b/locales/mn/common.json index d7bfaae1f..4ae828b62 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "{subject}-ийн тэмдэглэлийг хаах", "banner": "Хэрэгжээгүй өөрчлөлтүүд: {count}", "toast": "Офлайн хийсэн, хэрэгжээгүй өөрчлөлтүүд: {count}. Аль нь болохыг Илгээх хайрцгаас харна уу.", - "storage_failed": "Энэ төхөөрөмжид офлайн захидалд зай үлдсэнгүй. Зай чөлөөлөх эсвэл тохиргооноос цөөнийг хадгална уу." + "storage_failed": "Энэ төхөөрөмжид офлайн захидалд зай үлдсэнгүй. Зай чөлөөлөх эсвэл тохиргооноос цөөнийг хадгална уу.", + "signout_changes_title": "Хадгалаагүй өөрчлөлттэй гарах уу?", + "signout_changes": "Сервер дээр хараахан ороогүй өөрчлөлт: {count}. Гарвал устгагдана.", + "store_unavailable": "Энэ төхөөрөмжийн офлайн захидлыг одоо нээж чадахгүй байна. Илгээгээгүй захидал энд үлдэх бөгөөд апп үргэлжлүүлэн оролдоно.", + "store_reset": "Энэ төхөөрөмжийн офлайн захидлыг уншиж чадсангүй тул дахин татаж байна. Илгээгээгүй зүйл алга болсон.", + "legacy_stranded": "Энэ хөтөч өмнөх хувилбарын хадгалсан илгээгээгүй захидлыг нээж чадахгүй. Хөтчөө шинэчилбэл буцаж магадгүй.", + "store_private": "Хувийн цонх офлайн ашиглах захидал хадгалж чадахгүй.", + "server_not_answering": "Шуудангийн сервер хариу өгөхгүй байна. Хэсэг хугацааны дараа дахин оролдоно уу." }, "offlineStorage": { "keep_description": "Таны имэйл, хуанли, харилцагчид болон сонгосон файлуудыг энэ хөтөч дээр шифрлэн хадгална. Офлайн үед имэйл уншиж, тэмдэглэж, зөөж болох ба бичсэн зүйл тань гарах хайрцагт хүлээнэ. Гарахад бүгд устна.", @@ -6550,5 +6557,32 @@ "next_issue": "Дараагийн зөв бичих эсвэл хэл зүйн алдаа", "previous_issue": "Өмнөх зөв бичих эсвэл хэл зүйн алдаа" } + }, + "pwaWindows": { + "open_in_window": "Шинэ цонхонд нээх", + "save_all_to_folder": "Бүгдийг хавтсанд хадгалах…", + "saved_all": "{count, plural, one {# хавсралтыг {folder} хавтсанд хадгаллаа.} other {# хавсралтыг {folder} хавтсанд хадгаллаа.}}", + "saved_partial": "{total} хавсралтаас {saved}-г {folder} хавтсанд хадгаллаа.", + "save_failed": "Хавсралтуудыг {folder} хавтсанд хадгалж чадсангүй.", + "popup_blocked": "Хөтөч шинэ цонхыг хаасан байна. Энэ сайтад гарч ирэх цонхыг зөвшөөрөөд дахин оролдоно уу.", + "opening": "Нээж байна…", + "session_ended_title": "Та энэ цонхонд гарсан байна", + "session_ended_body": "Аппыг нээж нэвтэрсний дараа энэ цонхыг дахин нээнэ үү.", + "open_app": "Аппыг нээх", + "close_window": "Цонхыг хаах", + "message_gone_title": "Энэ мессеж байхгүй болсон", + "message_gone_body": "Устгагдсан эсвэл өөр бүртгэл рүү зөөгдсөн.", + "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": "Энэ серверийн хаяг буруу байна." } } diff --git a/locales/nb/common.json b/locales/nb/common.json index 1f73582b6..540dc8bd6 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Lukk merknaden om {subject}", "banner": "Endringer som ikke ble utført: {count}", "toast": "Endringer gjort uten nett som ikke ble utført: {count}. Utboksen viser hvilke.", - "storage_failed": "Denne enheten har ikke mer plass til e-post uten nett. Frigjør plass, eller behold mindre i innstillingene." + "storage_failed": "Denne enheten har ikke mer plass til e-post uten nett. Frigjør plass, eller behold mindre i innstillingene.", + "signout_changes_title": "Logge ut med endringer som ikke er lagret?", + "signout_changes": "Endringer som ennå ikke er på serveren: {count}. Når du logger ut, slettes de.", + "store_unavailable": "E-post uten nett på denne enheten kan ikke åpnes nå. Usendt e-post blir her, og appen fortsetter å prøve.", + "store_reset": "E-post uten nett på denne enheten kunne ikke leses og lastes derfor ned på nytt. Det som ikke var sendt, er borte.", + "legacy_stranded": "Denne nettleseren kan ikke åpne usendt e-post fra en tidligere versjon. En oppdatering av nettleseren kan hente den tilbake.", + "store_private": "Private vinduer kan ikke beholde e-post for bruk uten nett.", + "server_not_answering": "E-postserveren svarer ikke. Prøv igjen om litt." }, "offlineStorage": { "keep_description": "Beholder e-posten, kalenderen, kontaktene og filene du velger, kryptert i denne nettleseren. Frakoblet kan du lese, flagge og flytte e-post, og det du skriver, venter i utboksen. Når du logger av, fjernes alt.", @@ -6550,5 +6557,32 @@ "next_issue": "Neste stave- eller grammatikkfeil", "previous_issue": "Forrige stave- eller grammatikkfeil" } + }, + "pwaWindows": { + "open_in_window": "Åpne i nytt vindu", + "save_all_to_folder": "Lagre alle i en mappe…", + "saved_all": "{count, plural, one {# vedlegg lagret i {folder}.} other {# vedlegg lagret i {folder}.}}", + "saved_partial": "{saved} av {total} vedlegg lagret i {folder}.", + "save_failed": "Kunne ikke lagre vedleggene i {folder}.", + "popup_blocked": "Nettleseren blokkerte det nye vinduet. Tillat popup-vinduer for dette nettstedet og prøv igjen.", + "opening": "Åpner…", + "session_ended_title": "Du er logget ut i dette vinduet", + "session_ended_body": "Åpne appen og logg inn, og åpne deretter dette vinduet på nytt.", + "open_app": "Åpne appen", + "close_window": "Lukk vindu", + "message_gone_title": "Denne meldingen er borte", + "message_gone_body": "Den ble slettet eller flyttet til en annen konto.", + "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." } } diff --git a/locales/nl/common.json b/locales/nl/common.json index b454337fd..375563100 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Melding over {subject} sluiten", "banner": "Niet doorgevoerde wijzigingen: {count}", "toast": "Offline gemaakte wijzigingen niet doorgevoerd: {count}. Het Postvak UIT laat zien welke.", - "storage_failed": "Dit apparaat heeft geen ruimte meer voor offline mail. Maak ruimte vrij of bewaar minder via Instellingen." + "storage_failed": "Dit apparaat heeft geen ruimte meer voor offline mail. Maak ruimte vrij of bewaar minder via Instellingen.", + "signout_changes_title": "Afmelden met niet-opgeslagen wijzigingen?", + "signout_changes": "Wijzigingen die nog niet op de server staan: {count}. Afmelden verwijdert ze.", + "store_unavailable": "De offline mail op dit apparaat kan nu niet worden geopend. Niet-verzonden mail blijft hier en de app blijft het proberen.", + "store_reset": "De offline mail op dit apparaat kon niet worden gelezen en wordt opnieuw gedownload. Wat hier nog niet verzonden was, is weg.", + "legacy_stranded": "Deze browser kan niet-verzonden mail van een eerdere versie niet openen. De browser bijwerken kan die terughalen.", + "store_private": "Privévensters kunnen geen mail bewaren voor offline gebruik.", + "server_not_answering": "De mailserver antwoordt niet. Probeer het zo meteen opnieuw." }, "offlineStorage": { "keep_description": "Bewaart je e-mail, agenda, contacten en gekozen bestanden versleuteld in deze browser. Offline kun je e-mail lezen, markeren en verplaatsen, en wat je schrijft wacht in Postvak UIT. Afmelden verwijdert alles.", @@ -6550,5 +6557,32 @@ "next_issue": "Volgende spel- of grammaticafout", "previous_issue": "Vorige spel- of grammaticafout" } + }, + "pwaWindows": { + "open_in_window": "Openen in nieuw venster", + "save_all_to_folder": "Alles in een map opslaan…", + "saved_all": "{count, plural, one {# bijlage opgeslagen in {folder}.} other {# bijlagen opgeslagen in {folder}.}}", + "saved_partial": "{saved} van {total} bijlagen opgeslagen in {folder}.", + "save_failed": "De bijlagen konden niet in {folder} worden opgeslagen.", + "popup_blocked": "De browser heeft het nieuwe venster geblokkeerd. Sta pop-ups toe voor deze site en probeer het opnieuw.", + "opening": "Openen…", + "session_ended_title": "Je bent afgemeld in dit venster", + "session_ended_body": "Open de app en meld je aan, en open dit venster daarna opnieuw.", + "open_app": "App openen", + "close_window": "Venster sluiten", + "message_gone_title": "Dit bericht is er niet meer", + "message_gone_body": "Het is verwijderd of naar een ander account verplaatst.", + "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." } } diff --git a/locales/pl/common.json b/locales/pl/common.json index 34a22cd03..1210cbbeb 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Odrzuć uwagę o {subject}", "banner": "Niezastosowane zmiany: {count}", "toast": "Niezastosowane zmiany wprowadzone offline: {count}. Skrzynka nadawcza pokazuje które.", - "storage_failed": "Na tym urządzeniu nie ma już miejsca na pocztę offline. Zwolnij miejsce lub zachowuj mniej w ustawieniach." + "storage_failed": "Na tym urządzeniu nie ma już miejsca na pocztę offline. Zwolnij miejsce lub zachowuj mniej w ustawieniach.", + "signout_changes_title": "Wylogować z niezapisanymi zmianami?", + "signout_changes": "Zmiany, których jeszcze nie ma na serwerze: {count}. Wylogowanie je usunie.", + "store_unavailable": "Nie można teraz otworzyć poczty offline na tym urządzeniu. Niewysłane wiadomości zostają tutaj, a aplikacja próbuje dalej.", + "store_reset": "Nie udało się odczytać poczty offline na tym urządzeniu, więc jest pobierana ponownie. To, co nie zostało wysłane, przepadło.", + "legacy_stranded": "Ta przeglądarka nie może otworzyć niewysłanych wiadomości z wcześniejszej wersji. Aktualizacja przeglądarki może je przywrócić.", + "store_private": "Okna prywatne nie mogą przechowywać poczty do użytku offline.", + "server_not_answering": "Serwer poczty nie odpowiada. Spróbuj ponownie za chwilę." }, "offlineStorage": { "keep_description": "Przechowuje Twoją pocztę, kalendarz, kontakty i wybrane pliki zaszyfrowane w tej przeglądarce. Offline możesz czytać, oznaczać i przenosić wiadomości, a to, co napiszesz, czeka w skrzynce nadawczej. Wylogowanie usuwa wszystko.", @@ -6550,5 +6557,32 @@ "next_issue": "Następny błąd pisowni lub gramatyki", "previous_issue": "Poprzedni błąd pisowni lub gramatyki" } + }, + "pwaWindows": { + "open_in_window": "Otwórz w nowym oknie", + "save_all_to_folder": "Zapisz wszystko w folderze…", + "saved_all": "{count, plural, one {Zapisano # załącznik w {folder}.} few {Zapisano # załączniki w {folder}.} other {Zapisano # załączników w {folder}.}}", + "saved_partial": "Zapisano {saved} z {total} załączników w {folder}.", + "save_failed": "Nie udało się zapisać załączników w {folder}.", + "popup_blocked": "Przeglądarka zablokowała nowe okno. Zezwól na wyskakujące okna dla tej strony i spróbuj ponownie.", + "opening": "Otwieranie…", + "session_ended_title": "Wylogowano w tym oknie", + "session_ended_body": "Otwórz aplikację i zaloguj się, a potem otwórz to okno ponownie.", + "open_app": "Otwórz aplikację", + "close_window": "Zamknij okno", + "message_gone_title": "Tej wiadomości już nie ma", + "message_gone_body": "Została usunięta lub przeniesiona na inne konto.", + "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." } } diff --git a/locales/pt/common.json b/locales/pt/common.json index 0e5413f17..a1a62277d 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Dispensar a nota sobre {subject}", "banner": "Alterações não aplicadas: {count}", "toast": "Alterações feitas offline não aplicadas: {count}. A caixa de saída mostra quais.", - "storage_failed": "Este dispositivo não tem espaço para o correio offline. Liberte espaço ou guarde menos nas definições." + "storage_failed": "Este dispositivo não tem espaço para o correio offline. Liberte espaço ou guarde menos nas definições.", + "signout_changes_title": "Terminar sessão com alterações não guardadas?", + "signout_changes": "Alterações ainda não enviadas para o servidor: {count}. Terminar sessão elimina-as.", + "store_unavailable": "O correio offline deste dispositivo não pode ser aberto agora. O correio por enviar fica aqui e a aplicação continua a tentar.", + "store_reset": "Não foi possível ler o correio offline deste dispositivo, por isso está a ser transferido de novo. O que estava por enviar perdeu-se.", + "legacy_stranded": "Este navegador não consegue abrir o correio por enviar guardado por uma versão anterior. Atualizar o navegador pode recuperá-lo.", + "store_private": "As janelas privadas não podem guardar correio para uso offline.", + "server_not_answering": "O servidor de correio não está a responder. Tente novamente daqui a pouco." }, "offlineStorage": { "keep_description": "Mantém o seu correio, calendário, contactos e os ficheiros que escolher, cifrados neste navegador. Offline pode ler, sinalizar e mover e-mails, e o que escrever espera na caixa de saída. Terminar a sessão remove tudo.", @@ -6550,5 +6557,32 @@ "next_issue": "Próximo erro de ortografia ou gramática", "previous_issue": "Erro de ortografia ou gramática anterior" } + }, + "pwaWindows": { + "open_in_window": "Abrir em nova janela", + "save_all_to_folder": "Guardar tudo numa pasta…", + "saved_all": "{count, plural, one {# anexo guardado em {folder}.} other {# anexos guardados em {folder}.}}", + "saved_partial": "{saved} de {total} anexos guardados em {folder}.", + "save_failed": "Não foi possível guardar os anexos em {folder}.", + "popup_blocked": "O navegador bloqueou a nova janela. Permita pop-ups para este site e tente novamente.", + "opening": "A abrir…", + "session_ended_title": "Terminou a sessão nesta janela", + "session_ended_body": "Abra a aplicação e inicie sessão, depois abra esta janela novamente.", + "open_app": "Abrir a aplicação", + "close_window": "Fechar janela", + "message_gone_title": "Esta mensagem já não existe", + "message_gone_body": "Foi eliminada ou movida para outra conta.", + "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." } } diff --git a/locales/ro/common.json b/locales/ro/common.json index 413843d3a..adb7069f6 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Închide nota despre {subject}", "banner": "Modificări neaplicate: {count}", "toast": "Modificări făcute offline care nu au fost aplicate: {count}. Căsuța de trimitere arată care.", - "storage_failed": "Acest dispozitiv nu mai are loc pentru e-mailul offline. Eliberați spațiu sau păstrați mai puțin din setări." + "storage_failed": "Acest dispozitiv nu mai are loc pentru e-mailul offline. Eliberați spațiu sau păstrați mai puțin din setări.", + "signout_changes_title": "Vă deconectați cu modificări nesalvate?", + "signout_changes": "Modificări care nu sunt încă pe server: {count}. Deconectarea le șterge.", + "store_unavailable": "E-mailul offline de pe acest dispozitiv nu poate fi deschis acum. E-mailul netrimis rămâne aici, iar aplicația continuă să încerce.", + "store_reset": "E-mailul offline de pe acest dispozitiv nu a putut fi citit, așa că se descarcă din nou. Ce nu fusese trimis s-a pierdut.", + "legacy_stranded": "Acest browser nu poate deschide e-mailul netrimis păstrat de o versiune anterioară. Actualizarea browserului îl poate recupera.", + "store_private": "Ferestrele private nu pot păstra e-mailuri pentru utilizare offline.", + "server_not_answering": "Serverul de e-mail nu răspunde. Încercați din nou imediat." }, "offlineStorage": { "keep_description": "Păstrează e-mailurile, calendarul, contactele și fișierele alese de dvs., criptate în acest browser. Offline puteți citi, marca și muta e-mailuri, iar ce scrieți așteaptă în Mesaje de trimis. La deconectare se șterge totul.", @@ -6550,5 +6557,32 @@ "next_issue": "Următoarea greșeală de ortografie sau gramatică", "previous_issue": "Greșeala de ortografie sau gramatică anterioară" } + }, + "pwaWindows": { + "open_in_window": "Deschide într-o fereastră nouă", + "save_all_to_folder": "Salvează tot într-un dosar…", + "saved_all": "{count, plural, one {# atașament salvat în {folder}.} few {# atașamente salvate în {folder}.} other {# de atașamente salvate în {folder}.}}", + "saved_partial": "{saved} din {total} atașamente salvate în {folder}.", + "save_failed": "Atașamentele nu au putut fi salvate în {folder}.", + "popup_blocked": "Browserul a blocat fereastra nouă. Permite ferestrele pop-up pentru acest site și încearcă din nou.", + "opening": "Se deschide…", + "session_ended_title": "Ești deconectat în această fereastră", + "session_ended_body": "Deschide aplicația și autentifică-te, apoi deschide din nou această fereastră.", + "open_app": "Deschide aplicația", + "close_window": "Închide fereastra", + "message_gone_title": "Acest mesaj nu mai există", + "message_gone_body": "A fost șters sau mutat în alt cont.", + "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ă." } } diff --git a/locales/ru/common.json b/locales/ru/common.json index dd3538569..3f3b0c228 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Закрыть заметку о {subject}", "banner": "Неприменённые изменения: {count}", "toast": "Изменения, внесённые офлайн и не применённые: {count}. Какие именно, видно в исходящих.", - "storage_failed": "На этом устройстве не осталось места для офлайн-почты. Освободите место или храните меньше в настройках." + "storage_failed": "На этом устройстве не осталось места для офлайн-почты. Освободите место или храните меньше в настройках.", + "signout_changes_title": "Выйти с несохранёнными изменениями?", + "signout_changes": "Изменения, которых ещё нет на сервере: {count}. При выходе они удалятся.", + "store_unavailable": "Офлайн-почту на этом устройстве сейчас не удаётся открыть. Неотправленные письма остаются здесь, а приложение продолжает попытки.", + "store_reset": "Офлайн-почту на этом устройстве не удалось прочитать, поэтому она загружается заново. Всё, что не было отправлено, потеряно.", + "legacy_stranded": "Этот браузер не может открыть неотправленные письма, сохранённые предыдущей версией. Обновление браузера может их вернуть.", + "store_private": "Приватные окна не могут хранить почту для работы офлайн.", + "server_not_answering": "Почтовый сервер не отвечает. Повторите попытку через минуту." }, "offlineStorage": { "keep_description": "Хранит вашу почту, календарь, контакты и выбранные файлы в зашифрованном виде в этом браузере. Без сети можно читать, помечать и перемещать письма, а написанное ждёт в исходящих. При выходе всё удаляется.", @@ -6550,5 +6557,32 @@ "next_issue": "Следующая орфографическая или грамматическая ошибка", "previous_issue": "Предыдущая орфографическая или грамматическая ошибка" } + }, + "pwaWindows": { + "open_in_window": "Открыть в новом окне", + "save_all_to_folder": "Сохранить всё в папку…", + "saved_all": "{count, plural, one {# вложение сохранено в {folder}.} few {# вложения сохранено в {folder}.} many {# вложений сохранено в {folder}.} other {# вложения сохранено в {folder}.}}", + "saved_partial": "Сохранено {saved} из {total} вложений в {folder}.", + "save_failed": "Не удалось сохранить вложения в {folder}.", + "popup_blocked": "Браузер заблокировал новое окно. Разрешите всплывающие окна для этого сайта и повторите попытку.", + "opening": "Открытие…", + "session_ended_title": "В этом окне вы вышли из системы", + "session_ended_body": "Откройте приложение и войдите, затем откройте это окно снова.", + "open_app": "Открыть приложение", + "close_window": "Закрыть окно", + "message_gone_title": "Этого письма больше нет", + "message_gone_body": "Оно удалено или перемещено в другую учётную запись.", + "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": "Этот адрес сервера недействителен." } } diff --git a/locales/sk/common.json b/locales/sk/common.json index 706c90fe2..d32f33f18 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Zavrieť poznámku o {subject}", "banner": "Nepoužité zmeny: {count}", "toast": "Nepoužité zmeny urobené offline: {count}. Ktoré to sú, ukazuje pošta na odoslanie.", - "storage_failed": "V tomto zariadení už nie je miesto na poštu offline. Uvoľnite miesto alebo v nastaveniach ponechajte menej." + "storage_failed": "V tomto zariadení už nie je miesto na poštu offline. Uvoľnite miesto alebo v nastaveniach ponechajte menej.", + "signout_changes_title": "Odhlásiť sa s neuloženými zmenami?", + "signout_changes": "Zmeny, ktoré ešte nie sú na serveri: {count}. Odhlásenie ich odstráni.", + "store_unavailable": "Poštu offline v tomto zariadení teraz nemožno otvoriť. Neodoslaná pošta tu zostáva a aplikácia to skúša ďalej.", + "store_reset": "Poštu offline v tomto zariadení sa nepodarilo prečítať, preto sa sťahuje znova. Čo nebolo odoslané, je preč.", + "legacy_stranded": "Tento prehliadač neotvorí neodoslanú poštu uloženú staršou verziou. Aktualizácia prehliadača ju môže vrátiť.", + "store_private": "Súkromné okná nemôžu uchovávať poštu na používanie offline.", + "server_not_answering": "Poštový server neodpovedá. Skúste to o chvíľu znova." }, "offlineStorage": { "keep_description": "Uchováva vašu poštu, kalendár, kontakty a vybrané súbory zašifrované v tomto prehliadači. Offline môžete poštu čítať, označovať a presúvať a to, čo napíšete, čaká na odoslanie. Odhlásenie všetko odstráni.", @@ -6550,5 +6557,32 @@ "next_issue": "Ďalšia pravopisná alebo gramatická chyba", "previous_issue": "Predchádzajúca pravopisná alebo gramatická chyba" } + }, + "pwaWindows": { + "open_in_window": "Otvoriť v novom okne", + "save_all_to_folder": "Uložiť všetko do priečinka…", + "saved_all": "{count, plural, one {# príloha uložená do {folder}.} few {# prílohy uložené do {folder}.} other {# príloh uložených do {folder}.}}", + "saved_partial": "Uložených {saved} z {total} príloh do {folder}.", + "save_failed": "Prílohy sa nepodarilo uložiť do {folder}.", + "popup_blocked": "Prehliadač zablokoval nové okno. Povoľte kontextové okná pre túto stránku a skúste to znova.", + "opening": "Otvára sa…", + "session_ended_title": "V tomto okne ste odhlásení", + "session_ended_body": "Otvorte aplikáciu, prihláste sa a potom toto okno otvorte znova.", + "open_app": "Otvoriť aplikáciu", + "close_window": "Zavrieť okno", + "message_gone_title": "Táto správa tu už nie je", + "message_gone_body": "Bola odstránená alebo presunutá do iného účtu.", + "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á." } } diff --git a/locales/th/common.json b/locales/th/common.json index ce9b58928..16063beec 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2099,7 +2099,14 @@ "dismiss_label": "ปิดหมายเหตุเกี่ยวกับ {subject}", "banner": "การเปลี่ยนแปลงที่ไม่ได้นำไปใช้: {count}", "toast": "การเปลี่ยนแปลงขณะออฟไลน์ที่ไม่ได้นำไปใช้: {count} ดูรายการได้ในกล่องขาออก", - "storage_failed": "อุปกรณ์นี้ไม่มีที่ว่างสำหรับอีเมลออฟไลน์แล้ว โปรดเพิ่มพื้นที่ว่างหรือเก็บให้น้อยลงในการตั้งค่า" + "storage_failed": "อุปกรณ์นี้ไม่มีที่ว่างสำหรับอีเมลออฟไลน์แล้ว โปรดเพิ่มพื้นที่ว่างหรือเก็บให้น้อยลงในการตั้งค่า", + "signout_changes_title": "ออกจากระบบทั้งที่มีการเปลี่ยนแปลงที่ยังไม่บันทึกหรือไม่", + "signout_changes": "การเปลี่ยนแปลงที่ยังไม่อยู่บนเซิร์ฟเวอร์: {count} การออกจากระบบจะลบรายการเหล่านี้", + "store_unavailable": "ตอนนี้เปิดอีเมลออฟไลน์บนอุปกรณ์นี้ไม่ได้ อีเมลที่ยังไม่ส่งยังอยู่ที่นี่ และแอปจะลองต่อไป", + "store_reset": "อ่านอีเมลออฟไลน์บนอุปกรณ์นี้ไม่ได้ จึงกำลังดาวน์โหลดใหม่ สิ่งที่ยังไม่ได้ส่งหายไปแล้ว", + "legacy_stranded": "เบราว์เซอร์นี้เปิดอีเมลที่ยังไม่ส่งซึ่งเวอร์ชันก่อนหน้าเก็บไว้ไม่ได้ การอัปเดตเบราว์เซอร์อาจนำกลับมาได้", + "store_private": "หน้าต่างส่วนตัวเก็บอีเมลไว้ใช้แบบออฟไลน์ไม่ได้", + "server_not_answering": "เซิร์ฟเวอร์อีเมลไม่ตอบสนอง โปรดลองอีกครั้งในอีกสักครู่" }, "offlineStorage": { "keep_description": "เก็บอีเมล ปฏิทิน รายชื่อติดต่อ และไฟล์ที่คุณเลือกไว้ในเบราว์เซอร์นี้แบบเข้ารหัส เมื่อออฟไลน์คุณอ่าน ติดธง และย้ายอีเมลได้ และสิ่งที่คุณเขียนจะรออยู่ในกล่องขาออก เมื่อออกจากระบบทุกอย่างจะถูกลบ", @@ -6558,5 +6565,32 @@ "next_issue": "ปัญหาการสะกดหรือไวยากรณ์ถัดไป", "previous_issue": "ปัญหาการสะกดหรือไวยากรณ์ก่อนหน้า" } + }, + "pwaWindows": { + "open_in_window": "เปิดในหน้าต่างใหม่", + "save_all_to_folder": "บันทึกทั้งหมดลงในโฟลเดอร์…", + "saved_all": "{count, plural, other {บันทึกไฟล์แนบ # รายการลงใน {folder} แล้ว}}", + "saved_partial": "บันทึกไฟล์แนบ {saved} จาก {total} รายการลงใน {folder} แล้ว", + "save_failed": "ไม่สามารถบันทึกไฟล์แนบลงใน {folder} ได้", + "popup_blocked": "เบราว์เซอร์บล็อกหน้าต่างใหม่ โปรดอนุญาตป๊อปอัปสำหรับเว็บไซต์นี้แล้วลองอีกครั้ง", + "opening": "กำลังเปิด…", + "session_ended_title": "คุณออกจากระบบในหน้าต่างนี้แล้ว", + "session_ended_body": "เปิดแอปและลงชื่อเข้าใช้ จากนั้นเปิดหน้าต่างนี้อีกครั้ง", + "open_app": "เปิดแอป", + "close_window": "ปิดหน้าต่าง", + "message_gone_title": "ไม่มีข้อความนี้แล้ว", + "message_gone_body": "ข้อความถูกลบหรือย้ายไปยังบัญชีอื่น", + "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": "ที่อยู่เซิร์ฟเวอร์นี้ไม่ถูกต้อง" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index 1d41f5f66..5487fdd54 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "{subject} ile ilgili notu kapat", "banner": "Uygulanmayan değişiklikler: {count}", "toast": "Çevrimdışı yapılan ve uygulanmayan değişiklikler: {count}. Giden kutusu hangileri olduğunu gösterir.", - "storage_failed": "Bu cihazda çevrimdışı e-posta için yer kalmadı. Yer açın veya Ayarlar'dan daha azını saklayın." + "storage_failed": "Bu cihazda çevrimdışı e-posta için yer kalmadı. Yer açın veya Ayarlar'dan daha azını saklayın.", + "signout_changes_title": "Kaydedilmemiş değişikliklerle oturum kapatılsın mı?", + "signout_changes": "Henüz sunucuda olmayan değişiklikler: {count}. Oturumu kapatmak bunları siler.", + "store_unavailable": "Bu cihazdaki çevrimdışı e-postalar şu anda açılamıyor. Gönderilmemiş e-postalar burada kalır ve uygulama denemeye devam eder.", + "store_reset": "Bu cihazdaki çevrimdışı e-postalar okunamadı, bu yüzden yeniden indiriliyor. Gönderilmemiş olanlar kayboldu.", + "legacy_stranded": "Bu tarayıcı, önceki bir sürümün sakladığı gönderilmemiş e-postaları açamıyor. Tarayıcıyı güncellemek bunları geri getirebilir.", + "store_private": "Gizli pencereler e-postaları çevrimdışı kullanım için saklayamaz.", + "server_not_answering": "E-posta sunucusu yanıt vermiyor. Birazdan yeniden deneyin." }, "offlineStorage": { "keep_description": "E-postalarınızı, takviminizi, kişilerinizi ve seçtiğiniz dosyaları bu tarayıcıda şifreli olarak tutar. Çevrimdışıyken e-postaları okuyabilir, işaretleyebilir ve taşıyabilirsiniz; yazdıklarınız giden kutusunda bekler. Oturumu kapatınca hepsi silinir.", @@ -6550,5 +6557,32 @@ "next_issue": "Sonraki yazım veya dil bilgisi hatası", "previous_issue": "Önceki yazım veya dil bilgisi hatası" } + }, + "pwaWindows": { + "open_in_window": "Yeni pencerede aç", + "save_all_to_folder": "Tümünü bir klasöre kaydet…", + "saved_all": "{count, plural, one {# ek {folder} klasörüne kaydedildi.} other {# ek {folder} klasörüne kaydedildi.}}", + "saved_partial": "{total} ekten {saved} tanesi {folder} klasörüne kaydedildi.", + "save_failed": "Ekler {folder} klasörüne kaydedilemedi.", + "popup_blocked": "Tarayıcı yeni pencereyi engelledi. Bu site için açılır pencerelere izin verip yeniden deneyin.", + "opening": "Açılıyor…", + "session_ended_title": "Bu pencerede oturumunuz kapalı", + "session_ended_body": "Uygulamayı açıp oturum açın, ardından bu pencereyi yeniden açın.", + "open_app": "Uygulamayı aç", + "close_window": "Pencereyi kapat", + "message_gone_title": "Bu ileti artık yok", + "message_gone_body": "Silindi veya başka bir hesaba taşındı.", + "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." } } diff --git a/locales/uk/common.json b/locales/uk/common.json index f18e2f092..0ca8dc4b7 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "Закрити примітку про {subject}", "banner": "Незастосовані зміни: {count}", "toast": "Зміни, внесені офлайн і не застосовані: {count}. Які саме, видно у вихідних.", - "storage_failed": "На цьому пристрої не залишилося місця для офлайн-пошти. Звільніть місце або зберігайте менше в налаштуваннях." + "storage_failed": "На цьому пристрої не залишилося місця для офлайн-пошти. Звільніть місце або зберігайте менше в налаштуваннях.", + "signout_changes_title": "Вийти з незбереженими змінами?", + "signout_changes": "Зміни, яких ще немає на сервері: {count}. Під час виходу вони видаляться.", + "store_unavailable": "Офлайн-пошту на цьому пристрої зараз не вдається відкрити. Ненадіслані листи залишаються тут, а застосунок продовжує спроби.", + "store_reset": "Офлайн-пошту на цьому пристрої не вдалося прочитати, тому вона завантажується знову. Усе, що не було надіслано, втрачено.", + "legacy_stranded": "Цей браузер не може відкрити ненадіслані листи, збережені попередньою версією. Оновлення браузера може їх повернути.", + "store_private": "Приватні вікна не можуть зберігати пошту для роботи офлайн.", + "server_not_answering": "Поштовий сервер не відповідає. Спробуйте ще раз за хвилину." }, "offlineStorage": { "keep_description": "Зберігає вашу пошту, календар, контакти та вибрані файли в зашифрованому вигляді в цьому браузері. Без мережі можна читати, позначати й переміщати листи, а написане чекає у вихідних. Під час виходу все видаляється.", @@ -6550,5 +6557,32 @@ "next_issue": "Наступна орфографічна або граматична помилка", "previous_issue": "Попередня орфографічна або граматична помилка" } + }, + "pwaWindows": { + "open_in_window": "Відкрити в новому вікні", + "save_all_to_folder": "Зберегти все в папку…", + "saved_all": "{count, plural, one {# вкладення збережено в {folder}.} few {# вкладення збережено в {folder}.} many {# вкладень збережено в {folder}.} other {# вкладення збережено в {folder}.}}", + "saved_partial": "Збережено {saved} із {total} вкладень у {folder}.", + "save_failed": "Не вдалося зберегти вкладення в {folder}.", + "popup_blocked": "Браузер заблокував нове вікно. Дозвольте спливаючі вікна для цього сайту і спробуйте ще раз.", + "opening": "Відкриття…", + "session_ended_title": "У цьому вікні ви вийшли з облікового запису", + "session_ended_body": "Відкрийте застосунок і увійдіть, а потім відкрийте це вікно знову.", + "open_app": "Відкрити застосунок", + "close_window": "Закрити вікно", + "message_gone_title": "Цього листа більше немає", + "message_gone_body": "Його видалено або переміщено в інший обліковий запис.", + "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": "Ця адреса сервера недійсна." } } diff --git a/locales/vi/common.json b/locales/vi/common.json index a1b0f4125..275042a5e 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5766,7 +5766,14 @@ "dismiss_label": "Đóng ghi chú về {subject}", "banner": "Các thay đổi chưa được áp dụng: {count}", "toast": "Thay đổi khi ngoại tuyến chưa được áp dụng: {count}. Hộp thư đi cho biết là những thay đổi nào.", - "storage_failed": "Thiết bị này không còn chỗ cho thư ngoại tuyến. Hãy giải phóng dung lượng hoặc giữ ít hơn trong Cài đặt." + "storage_failed": "Thiết bị này không còn chỗ cho thư ngoại tuyến. Hãy giải phóng dung lượng hoặc giữ ít hơn trong Cài đặt.", + "signout_changes_title": "Đăng xuất khi còn thay đổi chưa lưu?", + "signout_changes": "Thay đổi chưa có trên máy chủ: {count}. Đăng xuất sẽ xóa chúng.", + "store_unavailable": "Hiện không mở được thư ngoại tuyến trên thiết bị này. Thư chưa gửi vẫn ở đây và ứng dụng tiếp tục thử.", + "store_reset": "Không đọc được thư ngoại tuyến trên thiết bị này nên đang tải lại. Những gì chưa gửi đã mất.", + "legacy_stranded": "Trình duyệt này không mở được thư chưa gửi do phiên bản trước lưu. Cập nhật trình duyệt có thể lấy lại chúng.", + "store_private": "Cửa sổ riêng tư không thể giữ thư để dùng ngoại tuyến.", + "server_not_answering": "Máy chủ thư không phản hồi. Hãy thử lại sau giây lát." }, "offlineStorage": { "keep_description": "Giữ thư, lịch, danh bạ và các tệp bạn chọn, được mã hóa trong trình duyệt này. Khi ngoại tuyến bạn có thể đọc, gắn cờ và di chuyển thư, còn thư bạn viết sẽ chờ trong Hộp thư đi. Khi đăng xuất, tất cả sẽ bị xóa.", @@ -6562,5 +6569,32 @@ "next_issue": "Lỗi chính tả hoặc ngữ pháp tiếp theo", "previous_issue": "Lỗi chính tả hoặc ngữ pháp trước đó" } + }, + "pwaWindows": { + "open_in_window": "Mở trong cửa sổ mới", + "save_all_to_folder": "Lưu tất cả vào thư mục…", + "saved_all": "{count, plural, other {Đã lưu # tệp đính kèm vào {folder}.}}", + "saved_partial": "Đã lưu {saved} trong {total} tệp đính kèm vào {folder}.", + "save_failed": "Không thể lưu tệp đính kèm vào {folder}.", + "popup_blocked": "Trình duyệt đã chặn cửa sổ mới. Hãy cho phép cửa sổ bật lên với trang này rồi thử lại.", + "opening": "Đang mở…", + "session_ended_title": "Bạn đã đăng xuất trong cửa sổ này", + "session_ended_body": "Hãy mở ứng dụng và đăng nhập, rồi mở lại cửa sổ này.", + "open_app": "Mở ứng dụng", + "close_window": "Đóng cửa sổ", + "message_gone_title": "Thư này không còn nữa", + "message_gone_body": "Thư đã bị xóa hoặc chuyển sang tài khoản khác.", + "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ệ." } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 261013184..67614a5dd 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "關閉關於 {subject} 的提示", "banner": "未套用的變更:{count}", "toast": "離線時的變更未被套用:{count} 項。可在寄件匣中查看。", - "storage_failed": "此裝置已沒有空間存放離線郵件。請釋出空間,或在設定中減少保留的內容。" + "storage_failed": "此裝置已沒有空間存放離線郵件。請釋出空間,或在設定中減少保留的內容。", + "signout_changes_title": "要在有未儲存變更時登出嗎?", + "signout_changes": "尚未到達伺服器的變更:{count} 項。登出會刪除它們。", + "store_unavailable": "目前無法開啟此裝置上的離線郵件。未傳送的郵件仍保留在這裡,應用程式會繼續嘗試。", + "store_reset": "無法讀取此裝置上的離線郵件,正在重新下載。未傳送的內容已遺失。", + "legacy_stranded": "此瀏覽器無法開啟舊版本保存的未傳送郵件。更新瀏覽器可能會找回它們。", + "store_private": "私密視窗無法保留供離線使用的郵件。", + "server_not_answering": "郵件伺服器沒有回應。請稍後再試。" }, "offlineStorage": { "keep_description": "在此瀏覽器中加密保留你的郵件、行事曆、聯絡人和你選擇的檔案。離線時可以閱讀、標記和移動郵件,寫好的郵件會在寄件匣中等待。登出時會全部刪除。", @@ -6550,5 +6557,32 @@ "next_issue": "下一個拼字或文法問題", "previous_issue": "上一個拼字或文法問題" } + }, + "pwaWindows": { + "open_in_window": "在新視窗中開啟", + "save_all_to_folder": "全部儲存到資料夾…", + "saved_all": "{count, plural, other {已將 # 個附件儲存到 {folder}。}}", + "saved_partial": "已將 {total} 個附件中的 {saved} 個儲存到 {folder}。", + "save_failed": "無法將附件儲存到 {folder}。", + "popup_blocked": "瀏覽器封鎖了新視窗。請允許此網站顯示彈出式視窗,然後再試一次。", + "opening": "正在開啟…", + "session_ended_title": "你已在此視窗中登出", + "session_ended_body": "請開啟應用程式並登入,然後重新開啟此視窗。", + "open_app": "開啟應用程式", + "close_window": "關閉視窗", + "message_gone_title": "這封郵件已不存在", + "message_gone_body": "它已被刪除或移到其他帳戶。", + "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": "此伺服器位址無效。" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index ae36afa3e..20e0799ca 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5754,7 +5754,14 @@ "dismiss_label": "关闭关于 {subject} 的提示", "banner": "未应用的更改:{count}", "toast": "离线时的更改未被应用:{count} 项。可在发件箱中查看。", - "storage_failed": "此设备已没有空间存放离线邮件。请释放空间,或在设置中减少保留的内容。" + "storage_failed": "此设备已没有空间存放离线邮件。请释放空间,或在设置中减少保留的内容。", + "signout_changes_title": "要在有未保存更改时退出登录吗?", + "signout_changes": "尚未到达服务器的更改:{count} 项。退出登录会删除它们。", + "store_unavailable": "现在无法打开此设备上的离线邮件。未发送的邮件仍保留在这里,应用会继续尝试。", + "store_reset": "无法读取此设备上的离线邮件,正在重新下载。未发送的内容已丢失。", + "legacy_stranded": "此浏览器无法打开早期版本保存的未发送邮件。更新浏览器可能会找回它们。", + "store_private": "隐私窗口无法保留供离线使用的邮件。", + "server_not_answering": "邮件服务器没有响应。请稍后再试。" }, "offlineStorage": { "keep_description": "在此浏览器中加密保留你的邮件、日历、联系人和你选择的文件。离线时可以阅读、标记和移动邮件,写好的邮件会在发件箱中等待。退出登录时会全部删除。", @@ -6550,5 +6557,32 @@ "next_issue": "下一个拼写或语法问题", "previous_issue": "上一个拼写或语法问题" } + }, + "pwaWindows": { + "open_in_window": "在新窗口中打开", + "save_all_to_folder": "全部保存到文件夹…", + "saved_all": "{count, plural, other {已将 # 个附件保存到 {folder}。}}", + "saved_partial": "已将 {total} 个附件中的 {saved} 个保存到 {folder}。", + "save_failed": "无法将附件保存到 {folder}。", + "popup_blocked": "浏览器阻止了新窗口。请允许此网站显示弹出窗口,然后重试。", + "opening": "正在打开…", + "session_ended_title": "你已在此窗口中退出登录", + "session_ended_body": "请打开应用并登录,然后重新打开此窗口。", + "open_app": "打开应用", + "close_window": "关闭窗口", + "message_gone_title": "这封邮件已不存在", + "message_gone_body": "它已被删除或移动到其他账户。", + "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": "此服务器地址无效。" } } diff --git a/next.config.ts b/next.config.ts index 1ce8a7714..b9bfbfb35 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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(), }, }; diff --git a/package.json b/package.json index 52adefb78..f9ca56924 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/proxy.ts b/proxy.ts index 4e47be5e8..92c1719e7 100644 --- a/proxy.ts +++ b/proxy.ts @@ -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 diff --git a/public/sw.js b/public/sw.js index 4ef34860c..22e4174ba 100644 --- a/public/sw.js +++ b/public/sw.js @@ -108,7 +108,7 @@ if (typeof caches !== "undefined" && caches && typeof caches.open === "function" readOfflineShell(); } -const APP_SECTIONS = new Set(["", "mail", "settings", "calendar", "contacts", "files", "pro", "apps", "protocol"]); +const APP_SECTIONS = new Set(["", "mail", "settings", "calendar", "contacts", "files", "pro", "apps", "protocol", "popout"]); /** The page path inside the app ("/mail/inbox"), or null for anything that isn't a page of the app. */ function appPagePath(url) { @@ -141,7 +141,12 @@ function documentKey(url) { async function cachedDocument(url) { const path = appPagePath(url) || "/"; const section = "/" + (path.split("/").filter(Boolean)[0] || ""); - const candidates = [documentKey(url), self.location.origin + BASE_PATH + (section === "/" ? "" : section), self.location.origin + BASE_PATH]; + const appRoot = self.location.origin + BASE_PATH; + // A pop-out has no section page, and the start page would put the whole app + // into its window. Its own kind of page reads the id from the address bar. + const candidates = section === "/popout" + ? [documentKey(url), appRoot + (path.startsWith("/popout/message/") ? "/popout/message/_" : "/popout/compose")] + : [documentKey(url), appRoot + (section === "/" ? "" : section), appRoot]; // Only looked into when it exists: opening it would create it again // after the next build's worker deleted it. if (await caches.has(SHELL_CACHE)) { diff --git a/scripts/build-desktop.mjs b/scripts/build-desktop.mjs new file mode 100644 index 000000000..5240927ba --- /dev/null +++ b/scripts/build-desktop.mjs @@ -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); +} diff --git a/scripts/desktop-build-flag.mjs b/scripts/desktop-build-flag.mjs new file mode 100644 index 000000000..26f6c62d0 --- /dev/null +++ b/scripts/desktop-build-flag.mjs @@ -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' : ''; +} diff --git a/scripts/lite/lib.mjs b/scripts/lite/lib.mjs index 9f64367d7..9396192d3 100644 --- a/scripts/lite/lib.mjs +++ b/scripts/lite/lib.mjs @@ -41,6 +41,7 @@ export const LITE_REMOVED_PATHS = [ "app/(main)/[...rest]", "app/(main)/[locale]/[...rest]", "app/(main)/protocol", + "app/(main)/[locale]/popout", "e2e", "integration", // The local store: Lite has no offline mode. A dev checkout may hold these. diff --git a/scripts/standalone-guard.mjs b/scripts/standalone-guard.mjs index 80aaff1de..192cc94e8 100644 --- a/scripts/standalone-guard.mjs +++ b/scripts/standalone-guard.mjs @@ -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) { diff --git a/stores/__tests__/auth-store-keep-local.test.ts b/stores/__tests__/auth-store-keep-local.test.ts new file mode 100644 index 000000000..99ba77f90 --- /dev/null +++ b/stores/__tests__/auth-store-keep-local.test.ts @@ -0,0 +1,225 @@ +import 'fake-indexeddb/auto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// What an automatic sign-out does to the mail on this device. A real refusal of +// the login wipes it, like a sign-out. An outage, or a refusal that says nothing +// about the login, keeps it for the next sign-in. + +const purge = vi.hoisted(() => ({ files: 0 })); +vi.mock('@/lib/store/client/purge', () => ({ purgeStoreFiles: async () => { purge.files += 1; }, deleteStorePool: async () => {} })); +vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })) })); + +let useAuthStore: typeof import('../auth-store').useAuthStore; +let useAccountStore: typeof import('../account-store').useAccountStore; +let state: typeof import('@/lib/offline/state'); +let offline: typeof import('@/lib/offline/offline-mode'); +let queue: typeof import('@/lib/offline/queue'); +let lost: typeof import('@/lib/offline/lost'); +let store: import('@/lib/store/__tests__/helpers/in-process-store').InProcessStore; + +const ALICE = 'alice@mail.example.test'; + +function entry(id: string, cookieSlot: number) { + return { + id, label: id, serverUrl: 'https://mail.example.test', username: id.split('@mail')[0], authMode: 'oauth', rememberMe: true, + displayName: id, email: id, lastLoginAt: Date.now(), isConnected: true, hasError: false, isDefault: cookieSlot === 0, cookieSlot, avatarColor: '#000', + }; +} + +function answer(status: number, body: unknown = {}): Response { + return new Response(typeof body === 'string' ? body : JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +function tokenAnswers(next: () => Response): void { + vi.stubGlobal('fetch', vi.fn(async (input: unknown) => (String(input).includes('/api/auth/token') ? next() : answer(200)))); +} + +/** `times`: as often as the retry timer would run it. */ +async function refresh(times = 1): Promise<void> { + for (let i = 0; i < times; i += 1) await useAuthStore.getState().refreshAccessToken({ accountId: ALICE }); + await vi.waitFor(() => expect(useAuthStore.getState().isAuthenticated).toBe(false)); +} + +beforeEach(async () => { + vi.resetModules(); + purge.files = 0; + localStorage.clear(); + sessionStorage.clear(); + ({ useAuthStore } = await import('../auth-store')); + ({ useAccountStore } = await import('../account-store')); + state = await import('@/lib/offline/state'); + offline = await import('@/lib/offline/offline-mode'); + queue = await import('@/lib/offline/queue'); + lost = await import('@/lib/offline/lost'); + const browserNavigation = await import('@/lib/browser-navigation'); + vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {}); + const { InProcessStore } = await import('@/lib/store/__tests__/helpers/in-process-store'); + const { setStoreBackend } = await import('@/lib/store/client/api'); + store = new InProcessStore(); + setStoreBackend(store); + useAccountStore.setState({ accounts: [entry(ALICE, 0)], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ isAuthenticated: true, activeAccountId: ALICE, authMode: 'oauth', serverUrl: 'https://mail.example.test', username: 'alice' }); + state.writeEnabled([ALICE]); + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); +}, 60_000); + +function expectKept(): void { + expect(purge.files).toBe(0); + expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]); + expect(localStorage.getItem(state.OFFLINE_ACCOUNTS_KEY)).toContain(ALICE); + expect(state.isEvicted(ALICE)).toBe(true); + expect(store.removed).toEqual([]); + expect(store.stopped).toBe(0); + // Nothing was lost, so nothing is named at the sign-in page. + expect(lost.consumeLost()).toEqual([]); +} + +describe('an automatic sign-out of the active account', () => { + it('keeps the data through a run of 502s from a proxy', async () => { + tokenAnswers(() => answer(502, '<html>Bad Gateway</html>')); + await refresh(5); + expectKept(); + }); + + it('keeps the data through a run of the route\'s own 500s', async () => { + tokenAnswers(() => answer(500, { error: 'Internal server error' })); + await refresh(5); + expectKept(); + }); + + it('keeps the data on a refusal that says nothing about the login', async () => { + tokenAnswers(() => answer(401, { error: 'Refresh failed' })); + await refresh(); + expectKept(); + }); + + it('keeps the data on a refusal it can\'t read', async () => { + tokenAnswers(() => answer(401, 'nope')); + await refresh(); + expectKept(); + }); + + it('wipes on a real invalid_grant, and names the lost mail', async () => { + tokenAnswers(() => answer(401, { error: 'invalid_grant' })); + offline.startOfflineMode(); + await refresh(); + await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + expect(localStorage.getItem(state.OFFLINE_ACCOUNTS_KEY)).toBeNull(); + expect(lost.consumeLost()).toEqual(['Unsent']); + }); + + it('wipes at the organisation\'s session limit', async () => { + const { SESSION_TOO_OLD } = await import('@/lib/session-policy/constants'); + tokenAnswers(() => answer(401, { error: SESSION_TOO_OLD, maxHours: 8 })); + await refresh(); + await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + }); +}); + +describe('a background account', () => { + const BOB = 'bob@mail.example.test'; + const token = async () => useAuthStore.getState().refreshAccessToken({ accountId: BOB }); + + beforeEach(() => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + state.writeEnabled([ALICE, BOB]); + offline.startOfflineMode(); + }); + + it('keeps its data on an unknown refusal', async () => { + tokenAnswers(() => answer(401, { error: 'Refresh failed' })); + await token(); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined()); + expect(state.isEvicted(BOB)).toBe(true); + expect(store.removed).toEqual([]); + }); + + it('wipes on a real invalid_grant', async () => { + tokenAnswers(() => answer(401, { error: 'invalid_grant' })); + await token(); + await vi.waitFor(() => expect(store.removed).toEqual([BOB])); + expect(state.isEvicted(BOB)).toBe(false); + }); +}); + +describe('data waiting for a sign-in', () => { + const BOB = 'bob@mail.example.test'; + + beforeEach(async () => { + state.writeEnabled([ALICE, BOB]); + await queue.queueSend(BOB, { subject: 'Bob\'s', to: [] }, { kind: 'email', args: [] }); + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + useAccountStore.getState().removeAccount(BOB, 'evicted'); + }); + + it('is hidden from anyone else, in every list', async () => { + await offline.refreshQueueState(); + expect(state.useOfflineStore.getState().outbox.map((row) => row.subject)).toEqual(['Unsent']); + expect((await queue.readQueue()).outbox.map((row) => row.account)).toEqual([ALICE]); + await offline.refreshUsage(); + expect(state.visibleAccounts()).toEqual([ALICE]); + }); + + it('shows again when the account signs in', async () => { + useAccountStore.getState().addAccount({ username: 'bob', serverUrl: 'https://mail.example.test', authMode: 'oauth', rememberMe: true, label: 'bob', displayName: 'bob', email: 'bob', lastLoginAt: 1, isConnected: true, hasError: false, isDefault: false } as never); + expect(state.isEvicted(BOB)).toBe(false); + await offline.refreshQueueState(); + expect(state.useOfflineStore.getState().outbox.map((row) => row.subject).sort()).toEqual(['Bob\'s', 'Unsent']); + }); + + it('goes after 30 days', async () => { + localStorage.setItem(state.OFFLINE_EVICTED_KEY, JSON.stringify({ [BOB]: Date.now() - state.EVICTED_KEEP_MS - 1000 })); + offline.startOfflineMode(); + await vi.waitFor(() => expect(store.removed).toEqual([BOB])); + expect(state.isEvicted(BOB)).toBe(false); + expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]); + }); + + it('stays before that', async () => { + offline.startOfflineMode(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(store.removed).toEqual([]); + expect(state.useOfflineStore.getState().enabled).toEqual([ALICE, BOB]); + }); + + it('survives someone else\'s full sign-out, whose own data goes', async () => { + offline.startOfflineMode(); + await useAuthStore.getState().logout({ endProviderSession: false }); + await vi.waitFor(() => expect(store.removed).toEqual([ALICE])); + expect(purge.files).toBe(0); + expect(state.useOfflineStore.getState().enabled).toEqual([BOB]); + expect(state.isEvicted(BOB)).toBe(true); + }); +}); + +describe('a sign-out that has to drop another account', () => { + const BOB = 'bob@mail.example.test'; + + it('keeps that account\'s data for its next sign-in, since nobody asked it', async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), { ...entry(BOB, 1), hasError: true }] } as never); + state.writeEnabled([ALICE, BOB]); + await queue.queueSend(BOB, { subject: 'Bob\'s', to: [] }, { kind: 'email', args: [] }); + offline.startOfflineMode(); + tokenAnswers(() => answer(200)); + await useAuthStore.getState().logout({ endProviderSession: false }); + await vi.waitFor(() => expect(useAccountStore.getState().accounts).toEqual([])); + await vi.waitFor(() => expect(store.removed).toEqual([ALICE])); + expect(purge.files).toBe(0); + expect(state.isEvicted(BOB)).toBe(true); + expect(state.useOfflineStore.getState().enabled).toEqual([BOB]); + expect(lost.consumeLost()).not.toContain('Bob\'s'); + }); +}); + +describe('a login without "remember me"', () => { + it('ends with its data at a reload, naming lost mail, even before the host runs', async () => { + useAccountStore.setState({ accounts: [{ ...entry(ALICE, 0), rememberMe: false }] } as never); + tokenAnswers(() => answer(200)); + await useAuthStore.getState().checkAuth(); + expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined(); + expect(state.isEvicted(ALICE)).toBe(false); + offline.startOfflineMode(); + await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + expect(lost.consumeLost()).toEqual(['Unsent']); + }); +}); diff --git a/stores/__tests__/auth-store-local-copy.test.ts b/stores/__tests__/auth-store-local-copy.test.ts index 15881cc85..ee514fb01 100644 --- a/stores/__tests__/auth-store-local-copy.test.ts +++ b/stores/__tests__/auth-store-local-copy.test.ts @@ -7,11 +7,15 @@ const offline = vi.hoisted(() => ({ order: [] as string[], session: { apiUrl: 'x' } as unknown, gone: false, + ready: true, + // A local copy with no identities kept answers an empty list. + localIdentities: Promise.resolve([]) as Promise<unknown[]>, + releaseLocal: () => {}, })); vi.mock('@/lib/offline/offline-mode', () => ({ registerOfflineHooks: () => { offline.order.push('hooks'); }, - offlineSessionFor: async () => offline.session, + offlineSessionFor: async (_account: string, options?: { withFolders?: boolean }) => (options?.withFolders && !offline.ready ? null : offline.session), serverUnreachable: async () => offline.gone, markStartedOffline: () => { offline.order.push('startedOffline'); }, })); @@ -33,7 +37,9 @@ vi.mock('@/lib/jmap/client', () => { async connect() { offline.order.push('connect'); await connectGate; } getSessionUsername() { return this.username; } getUsername() { return this.username; } - async getIdentities() { return [{ id: 'a', email: this.username, name: this.username, mayDelete: false }]; } + async getIdentities() { + return this.offlineOnly ? offline.localIdentities : [{ id: 'a', email: this.username, name: this.username, mayDelete: false }]; + } getAuthHeader() { return 'Basic eA=='; } onConnectionChange() {} onRateLimit() {} @@ -88,11 +94,13 @@ beforeEach(async () => { offline.order.length = 0; offline.session = { apiUrl: 'x' }; offline.gone = false; + offline.ready = true; + offline.localIdentities = Promise.resolve([]); connectGate = Promise.resolve(); sessionAnswer = goodSession; useAccountStore.setState({ accounts: [ACCOUNT] as never, activeAccountId: ACCOUNT.id, defaultAccountId: ACCOUNT.id }); useOfflineStore.setState({ enabled: [ACCOUNT.id], startedOffline: false }); - useAuthStore.setState({ client: null, isAuthenticated: false, activeAccountId: ACCOUNT.id }); + useAuthStore.setState({ client: null, isAuthenticated: false, activeAccountId: ACCOUNT.id, identities: [], primaryIdentity: null }); }); type ClientLike = { isOfflineOnly(): boolean }; @@ -128,6 +136,23 @@ it('stays on the local copy when the server can\'t be reached', async () => { expect(useAccountStore.getState().getAccountById(ACCOUNT.id)).toBeTruthy(); }); +it('stays on the local copy, and says so, when the server answers a start with a 5xx or 429', async () => { + for (const status of [502, 429]) { + ACCOUNT = makeAccount(); + offline.order.length = 0; + useAccountStore.setState({ accounts: [ACCOUNT] as never, activeAccountId: ACCOUNT.id, defaultAccountId: ACCOUNT.id }); + useOfflineStore.setState({ enabled: [ACCOUNT.id] }); + useAuthStore.setState({ client: null, isAuthenticated: false, activeAccountId: ACCOUNT.id }); + sessionAnswer = () => new Response('{}', { status }); + // The probe passes: the server itself is there. + offline.gone = false; + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(offline.order).toContain('startedOffline')); + expect(current()?.isOfflineOnly()).toBe(true); + expect(useAccountStore.getState().getAccountById(ACCOUNT.id)).toBeTruthy(); + } +}); + it('goes the usual way when the store has no copy', async () => { offline.session = null; await useAuthStore.getState().checkAuth(); @@ -142,6 +167,41 @@ it('doesn\'t touch the store for an account without it', async () => { expect(current()?.isOfflineOnly()).toBe(false); }); +it('goes the usual way while the store has no folders yet', async () => { + offline.ready = false; + await useAuthStore.getState().checkAuth(); + expect(offline.order).not.toContain('local client'); + expect(current()?.isOfflineOnly()).toBe(false); +}); + +it('still starts on an empty store when the server can\'t be reached', async () => { + offline.ready = false; + offline.gone = true; + sessionAnswer = () => new Response('{}', { status: 503 }); + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(current()?.isOfflineOnly()).toBe(true)); +}); + +it('fills the From list from the server after the swap, when the local copy had none', async () => { + let open: () => void = () => {}; + connectGate = new Promise((resolve) => { open = resolve; }); + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(current()?.isOfflineOnly()).toBe(true)); + expect(useAuthStore.getState().identities).toEqual([]); + open(); + await vi.waitFor(() => expect(useAuthStore.getState().identities.map((identity) => identity.email)).toEqual([ACCOUNT.username])); + expect(useAuthStore.getState().primaryIdentity?.email).toBe(ACCOUNT.username); +}); + +it('doesn\'t let a late local answer overwrite the server\'s From list', async () => { + offline.localIdentities = new Promise((resolve) => { offline.releaseLocal = () => resolve([{ id: 'old', email: 'stale@x.test', name: 'Stale', mayDelete: false }]); }); + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(useAuthStore.getState().identities.map((identity) => identity.email)).toEqual([ACCOUNT.username])); + offline.releaseLocal(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(useAuthStore.getState().identities.map((identity) => identity.email)).toEqual([ACCOUNT.username]); +}); + it('leaves the login path to a refused account, and keeps its data', async () => { sessionAnswer = () => new Response('{}', { status: 401 }); await useAuthStore.getState().checkAuth(); diff --git a/stores/__tests__/auth-store-shared-session.test.ts b/stores/__tests__/auth-store-shared-session.test.ts new file mode 100644 index 000000000..8077e3631 --- /dev/null +++ b/stores/__tests__/auth-store-shared-session.test.ts @@ -0,0 +1,351 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel'; + +// A pop-out starts on the main window's session. It must not evict an account +// (a login without "remember me" has nothing to restore, which checkAuth +// answers by removing it from the list every window shares), and must not +// refresh on its own: without a cross-window lock a stale refresh can end in +// `Replay` on Stalwart 1.0, which revokes the grant. + +// Going on without a main window waits out its answer, 3 s on a timer that a busy machine stretches. +vi.setConfig({ testTimeout: 30_000 }); + +const made: Array<{ header: string; refresh?: () => Promise<string | null> }> = []; + +vi.mock('@/lib/jmap/client', () => { + class JMAPClient { + authHeader = ''; + onTokenRefresh?: () => Promise<string | null>; + constructor(public serverUrl: string, public username: string, public password: string) { + this.authHeader = `Basic ${password}`; + } + static withBearer(serverUrl: string, token: string, username: string, refresh?: () => Promise<string | null>) { + const client = new JMAPClient(serverUrl, username, ''); + client.authHeader = `Bearer ${token}`; + client.onTokenRefresh = refresh; + made.push({ header: client.authHeader, refresh }); + return client; + } + static withSharedAuth(serverUrl: string, username: string, header: string, refresh?: () => Promise<string | null>) { + const client = new JMAPClient(serverUrl, username, ''); + client.authHeader = header; + client.onTokenRefresh = refresh; + made.push({ header, refresh }); + return client; + } + async connect() {} + getSessionUsername() { return this.username; } + getUsername() { return this.username; } + async getIdentities() { return [{ id: 'i', email: this.username, name: this.username, mayDelete: false }]; } + getAuthHeader() { return this.authHeader; } + updateAccessToken(token: string) { this.authHeader = `Bearer ${token}`; } + onConnectionChange() {} + onRateLimit() {} + getRateLimitRemainingMs() { return 0; } + supportsContacts() { return false; } + supportsPrincipals() { return false; } + supportsVacationResponse() { return false; } + supportsCalendars() { return false; } + supportsSieve() { return false; } + disconnect() {} + getAccountId() { return 'acct'; } + isOfflineOnly() { return false; } + } + class RateLimitError extends Error {} + return { JMAPClient, RateLimitError }; +}); +vi.mock('@/lib/stalwart/principal', () => ({ fetchPrincipalDisplayName: async () => null })); + +import { openPopoutChannel, type PopoutChannel } from '@/lib/popout/channel'; +import { credentialFingerprint, sealFor } from '@/lib/popout/sealed'; +import { useAccountStore } from '@/stores/account-store'; +import { SHARED_SESSION_ENDED_EVENT, useAuthStore } from '@/stores/auth-store'; +import { SIGNED_OUT_KEY } from '@/lib/sign-out-cleanup'; + +const account = (id: string, slot: number, over: Record<string, unknown> = {}) => ({ + id, label: id, serverUrl: 'https://mail.example.test', username: `${id}@example.test`, authMode: 'oauth' as const, + rememberMe: true, cookieSlot: slot, displayName: id, email: `${id}@example.test`, isConnected: false, hasError: false, + lastLoginAt: 1, avatarColor: '#000', isDefault: slot === 0, ...over, +}); + +let fetched: Array<{ url: string; method?: string }>; +let tokenAnswers: Response[]; +let main: PopoutChannel | null; +let requests: Array<{ accountId?: string; rejectedFingerprint?: string }>; + +function startMainWindow(answer: (request: { accountId?: string; rejectedFingerprint?: string }) => { activeAccountId: string; sessions: unknown[] } | null) { + main = openPopoutChannel((message) => { + if (message.type !== 'session-request') return; + requests.push({ accountId: message.accountId, rejectedFingerprint: message.rejectedFingerprint }); + const result = answer(message); + if (result) void sealFor(message.publicKey, result).then((sealed) => main?.post({ type: 'session-answer', requestId: message.requestId, sealed })); + }); +} + +const session = (id: string, authHeader = 'Bearer main-token', authMode = 'oauth') => ({ + accountId: id, serverUrl: 'https://mail.example.test', username: `${id}@example.test`, authMode, authHeader, +}); + +const tokenCalls = () => fetched.filter((call) => call.url.includes('/api/auth/token')); +const sessionCalls = () => fetched.filter((call) => call.url.includes('/api/auth/session')); + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'], shouldAdvanceTime: true, advanceTimeDelta: 50 }); + FakeBroadcastChannel.reset(); + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + window.history.replaceState(null, '', '/en/popout/message/abc'); + made.length = 0; + fetched = []; + tokenAnswers = []; + requests = []; + main = null; + useAccountStore.setState({ accounts: [account('alice', 0)], activeAccountId: 'alice', defaultAccountId: 'alice' }); + useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null }); + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + fetched.push({ url: String(url), method: init?.method }); + if (String(url).includes('/api/auth/token')) { + return tokenAnswers.shift() ?? new Response(JSON.stringify({ access_token: 'cookie-token', expires_in: 3600 }), { status: 200 }); + } + return new Response('{}', { status: 200 }); + })); +}); + +afterEach(() => { + main?.close(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + window.history.replaceState(null, '', '/'); + localStorage.clear(); +}); + +describe('startSharedSession', () => { + it('uses the main window\'s session and asks no auth route for anything', async () => { + startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('alice')] })); + await expect(useAuthStore.getState().startSharedSession()).resolves.toBe('ready'); + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(true); + expect(state.activeAccountId).toBe('alice'); + expect(state.client?.getAuthHeader()).toBe('Bearer main-token'); + expect(tokenCalls()).toEqual([]); + expect(sessionCalls()).toEqual([]); + }); + + it('takes a login without "remember me" from the main window too', async () => { + useAccountStore.setState({ accounts: [account('alice', 0, { rememberMe: false, authMode: 'basic' })] }); + startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('alice', 'Basic abc', 'basic')] })); + await expect(useAuthStore.getState().startSharedSession()).resolves.toBe('ready'); + expect(useAuthStore.getState().client?.getAuthHeader()).toBe('Basic abc'); + }); + + it('asks for the login it wants, so only a window holding it answers', async () => { + useAccountStore.setState({ accounts: [account('alice', 0), account('bob', 1)] }); + startMainWindow(() => ({ activeAccountId: 'bob', sessions: [session('bob', 'Bearer bob-token')] })); + await useAuthStore.getState().startSharedSession({ accountId: 'bob' }); + expect(requests[0].accountId).toBe('bob'); + }); + + it('opens the login it was asked for, whatever the main window has active', async () => { + useAccountStore.setState({ accounts: [account('alice', 0), account('bob', 1)] }); + startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('alice'), session('bob', 'Bearer bob-token')] })); + await useAuthStore.getState().startSharedSession({ accountId: 'bob' }); + expect(useAuthStore.getState().activeAccountId).toBe('bob'); + expect(useAuthStore.getState().client?.getAuthHeader()).toBe('Bearer bob-token'); + }); + + it('never evicts: a login without "remember me" and no main window stays in the list', async () => { + useAccountStore.setState({ accounts: [account('alice', 0, { rememberMe: false, authMode: 'basic' })] }); + await expect(useAuthStore.getState().startSharedSession()).resolves.toBe('no-session'); + expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual(['alice']); + expect(sessionCalls().filter((call) => call.method === 'DELETE')).toEqual([]); + expect(tokenCalls().filter((call) => call.method === 'DELETE')).toEqual([]); + }); + + it('never evicts on a refused cookie either', async () => { + tokenAnswers.push(new Response('{}', { status: 401 })); + await expect(useAuthStore.getState().startSharedSession()).resolves.toBe('no-session'); + expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual(['alice']); + }); + + it('restores from the cookies when no main window is open, without forcing a renewal', async () => { + await expect(useAuthStore.getState().startSharedSession()).resolves.toBe('ready'); + expect(useAuthStore.getState().client?.getAuthHeader()).toBe('Bearer cookie-token'); + const [call] = tokenCalls(); + expect(call.url).not.toContain('force=true'); + }); + + it('does not write the list or the active account that the main window owns', async () => { + useAccountStore.setState({ accounts: [account('alice', 0), account('bob', 1)], activeAccountId: 'alice' }); + localStorage.setItem('account-registry', 'written by the main window'); + localStorage.setItem('auth-storage', 'written by the main window'); + startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('bob', 'Bearer bob-token')] })); + await useAuthStore.getState().startSharedSession({ accountId: 'bob' }); + expect(localStorage.getItem('account-registry')).toBe('written by the main window'); + expect(localStorage.getItem('auth-storage')).toBe('written by the main window'); + }); + + it('starts no refresh timer', async () => { + startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('alice')] })); + await useAuthStore.getState().startSharedSession(); + await vi.advanceTimersByTimeAsync(7_200_000); + expect(tokenCalls()).toEqual([]); + expect(requests).toHaveLength(1); + }); +}); + +describe('a token the server refused in a pop-out', () => { + async function started() { + startMainWindow((request) => { + const header = request.rejectedFingerprint ? 'Bearer renewed-by-main' : 'Bearer main-token'; + return { activeAccountId: 'alice', sessions: [session('alice', header)] }; + }); + await useAuthStore.getState().startSharedSession(); + return made[made.length - 1].refresh!; + } + + it('comes from the main window, which is told which one was refused', async () => { + const refresh = await started(); + await expect(refresh()).resolves.toBe('renewed-by-main'); + expect(requests[1]).toEqual({ accountId: 'alice', rejectedFingerprint: await credentialFingerprint('Bearer main-token') }); + expect(tokenCalls()).toEqual([]); + }); + + it('gives up when the main window could not renew it, without signing anyone out', async () => { + startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('alice')] })); + await useAuthStore.getState().startSharedSession(); + const refresh = made[made.length - 1].refresh!; + await expect(refresh()).resolves.toBeNull(); + expect(tokenCalls()).toEqual([]); + expect(useAccountStore.getState().accounts).toHaveLength(1); + }); + + it('comes through the locked cookie path once no main window is left', async () => { + const refresh = await started(); + main?.close(); + main = null; + const locks = { request: vi.fn((_name: string, _options: object, callback: (lock: unknown) => Promise<unknown>) => callback({})) }; + Object.defineProperty(navigator, 'locks', { value: locks, configurable: true }); + try { + await expect(refresh()).resolves.toBe('cookie-token'); + } finally { + Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true }); + } + expect(locks.request.mock.calls[0][0]).toBe('bulwark-token-refresh:0'); + expect(tokenCalls()).toHaveLength(1); + }); + + it('forces a renewal only when the cookie holds the refused token', async () => { + const refresh = await started(); + main?.close(); + main = null; + tokenAnswers.push(new Response(JSON.stringify({ access_token: 'main-token', expires_in: 3600 }), { status: 200 })); + await expect(refresh()).resolves.toBe('cookie-token'); + expect(tokenCalls().map((call) => call.url.includes('force=true'))).toEqual([false, true]); + }); + + it('says so and signs nobody out when the cookie is refused too', async () => { + const refresh = await started(); + main?.close(); + main = null; + tokenAnswers.push(new Response('{}', { status: 401 })); + const ended = vi.fn(); + window.addEventListener(SHARED_SESSION_ENDED_EVENT, ended); + await expect(refresh()).resolves.toBeNull(); + window.removeEventListener(SHARED_SESSION_ENDED_EVENT, ended); + expect(ended).toHaveBeenCalledTimes(1); + expect(useAccountStore.getState().accounts).toHaveLength(1); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + }); +}); + +describe('renewals in a main window', () => { + it('take the same lock, so a main window and a pop-out never race', async () => { + window.history.replaceState(null, '', '/mail'); + const locks = { request: vi.fn((_name: string, _options: object, callback: (lock: unknown) => Promise<unknown>) => callback({})) }; + Object.defineProperty(navigator, 'locks', { value: locks, configurable: true }); + try { + await useAuthStore.getState().refreshAccessToken({ accountId: 'alice', allowCached: true }); + } finally { + Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true }); + } + expect(locks.request.mock.calls.map((call) => call[0])).toContain('bulwark-token-refresh:0'); + }); + + /** One window holds the lock until `release`; this one queues behind it. */ + function contendedLocks() { + let release: () => void = () => {}; + const held = new Promise<void>((resolve) => { release = resolve; }); + const locks = { + request: vi.fn((_name: string, options: { ifAvailable?: boolean }, callback: (lock: unknown) => Promise<unknown>) => + (options.ifAvailable ? Promise.resolve(callback(null)) : held.then(() => callback({})))), + }; + Object.defineProperty(navigator, 'locks', { value: locks, configurable: true }); + return { release: () => release() }; + } + const clearLocks = () => Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true }); + + beforeEach(() => { + window.history.replaceState(null, '', '/mail'); + }); + afterEach(clearLocks); + + it('force a renewal when nobody else was at it', async () => { + const locks = { request: vi.fn((_name: string, _options: object, callback: (lock: unknown) => Promise<unknown>) => callback({})) }; + Object.defineProperty(navigator, 'locks', { value: locks, configurable: true }); + await useAuthStore.getState().refreshAccessToken({ accountId: 'alice' }); + expect(tokenCalls()[0].url).toContain('force=true'); + }); + + it('take the cached token after waiting for another window: a second renewal this soon can be refused', async () => { + const { release } = contendedLocks(); + const renewal = useAuthStore.getState().refreshAccessToken({ accountId: 'alice' }); + await vi.advanceTimersByTimeAsync(10); + expect(tokenCalls()).toEqual([]); + release(); + await expect(renewal).resolves.toBe('cookie-token'); + expect(tokenCalls()).toHaveLength(1); + expect(tokenCalls()[0].url).not.toContain('force=true'); + }); + + it('sign nobody out when the account left during the wait', async () => { + const logout = vi.fn(async () => {}); + const removeAccount = vi.fn(); + useAuthStore.setState({ logout: logout as never, removeAccount: removeAccount as never, activeAccountId: 'bob' }); + useAccountStore.setState({ accounts: [account('alice', 0), account('bob', 1)] }); + tokenAnswers.push(new Response('{}', { status: 401 })); + const { release } = contendedLocks(); + const renewal = useAuthStore.getState().refreshAccessToken({ accountId: 'alice' }); + await vi.advanceTimersByTimeAsync(10); + useAccountStore.setState({ accounts: [account('bob', 1)] }); + release(); + await expect(renewal).resolves.toBeNull(); + expect(logout).not.toHaveBeenCalled(); + expect(removeAccount).not.toHaveBeenCalled(); + }); + + it('judge who is active after the wait, not before: a refusal for a background account signs out only that one', async () => { + const logout = vi.fn(async () => {}); + const removeAccount = vi.fn(); + useAccountStore.setState({ accounts: [account('alice', 0), account('bob', 1)] }); + useAuthStore.setState({ logout: logout as never, removeAccount: removeAccount as never, activeAccountId: 'alice' }); + tokenAnswers.push(new Response('{}', { status: 401 })); + const { release } = contendedLocks(); + const renewal = useAuthStore.getState().refreshAccessToken({ accountId: 'alice' }); + await vi.advanceTimersByTimeAsync(10); + // The user switched to bob while it waited. The refused token is alice's. + useAuthStore.setState({ activeAccountId: 'bob' }); + release(); + await renewal; + expect(removeAccount).toHaveBeenCalledWith('alice', 'evicted'); + expect(logout).not.toHaveBeenCalled(); + }); +}); + +describe('everyone signing out in another window', () => { + it('leaves a pop-out alone here, which saves its typing first (popout-shell)', () => { + const close = vi.spyOn(window, 'close').mockImplementation(() => {}); + useAuthStore.setState({ isAuthenticated: true }); + window.dispatchEvent(new StorageEvent('storage', { key: SIGNED_OUT_KEY, newValue: '1' })); + expect(close).not.toHaveBeenCalled(); + close.mockRestore(); + }); +}); diff --git a/stores/__tests__/email-snapshot-account.test.ts b/stores/__tests__/email-snapshot-account.test.ts index 0b31fc020..c2ee1933d 100644 --- a/stores/__tests__/email-snapshot-account.test.ts +++ b/stores/__tests__/email-snapshot-account.test.ts @@ -38,52 +38,69 @@ describe('email boot snapshot', { timeout: 30_000 }, () => { }); }); -// With the local store on, the mail is encrypted on the device, and subjects in -// localStorage would give it away. -describe('email boot snapshot with the local store on', { timeout: 30_000 }, () => { - const ROW = { id: 'e1', subject: 'Secret subject', mailboxIds: { inbox: true } }; - const SNAPSHOT = { v: 3, account: 'alice@mail.example', mailboxes: MAILBOXES, selectedMailbox: 'inbox', emails: [ROW] }; +// With the local store on, mail doesn't go to localStorage in plain text too. +describe('email boot snapshot for an account in the local store', { timeout: 30_000 }, () => { + const STATE = { mailboxes: MAILBOXES, selectedMailbox: 'inbox', emails: [{ id: 'e1', mailboxIds: { inbox: true } }] }; - async function boot(enabled: string[]) { - persistSession('alice@mail.example'); - localStorage.setItem('email-snapshot', JSON.stringify(SNAPSHOT)); - localStorage.setItem('bulwark-offline-accounts', JSON.stringify(enabled)); + async function fresh(account: string) { + vi.useFakeTimers(); + persistSession(account); vi.resetModules(); - return (await import('@/stores/email-store')).useEmailStore; + const { useEmailStore } = await import('@/stores/email-store'); + const offline = await import('@/lib/offline/state'); + return { useEmailStore, offline }; } - it('is removed at the start and not restored', async () => { - const store = await boot(['alice@mail.example']); + afterEach(() => vi.useRealTimers()); + + it('isn\'t written', async () => { + const { useEmailStore, offline } = await fresh('alice@mail.example'); + offline.useOfflineStore.setState({ enabled: ['alice@mail.example'] }); + useEmailStore.setState(STATE as never); + await vi.advanceTimersByTimeAsync(1500); expect(localStorage.getItem('email-snapshot')).toBeNull(); - expect(store.getState().emails).toEqual([]); }); - it('still works for an account without it', async () => { - const store = await boot(['bob@mail.example']); + it('is written for an account outside it', async () => { + const { useEmailStore, offline } = await fresh('alice@mail.example'); + offline.useOfflineStore.setState({ enabled: ['bob@mail.example'] }); + useEmailStore.setState(STATE as never); + await vi.advanceTimersByTimeAsync(1500); + expect(JSON.parse(localStorage.getItem('email-snapshot') ?? '{}').account).toBe('alice@mail.example'); + }); + + it('goes only when that account\'s store can take over', async () => { + const { offline } = await fresh('alice@mail.example'); + localStorage.setItem('email-snapshot', JSON.stringify({ v: 3, account: 'alice@mail.example', mailboxes: MAILBOXES })); + offline.forgetMailSnapshot('bob@mail.example'); expect(localStorage.getItem('email-snapshot')).not.toBeNull(); - expect(store.getState().emails).toHaveLength(1); - }); - - it('is never written while it is on', async () => { - const store = await boot(['alice@mail.example']); - store.setState({ mailboxes: MAILBOXES as never, emails: [ROW] as never, selectedMailbox: 'inbox' }); - await new Promise((resolve) => setTimeout(resolve, 1100)); + offline.forgetMailSnapshot('alice@mail.example'); expect(localStorage.getItem('email-snapshot')).toBeNull(); }); +}); - it('is written without it, as the control', async () => { - const store = await boot([]); - store.setState({ emails: [{ ...ROW, id: 'e2' }] as never }); - await new Promise((resolve) => setTimeout(resolve, 1100)); - expect(localStorage.getItem('email-snapshot')).toContain('e2'); - }); - - it('goes the moment the user turns it on', async () => { - const store = await boot([]); - expect(store.getState().emails).toHaveLength(1); - const { useOfflineStore } = await import('@/lib/offline/state'); - useOfflineStore.setState({ enabled: ['alice@mail.example'] }); - expect(localStorage.getItem('email-snapshot')).toBeNull(); +// A client's late answer doesn't land on the client that replaced it. +describe('a list answer from a client that was replaced', { timeout: 30_000 }, () => { + it('is dropped', async () => { + persistSession('alice@mail.example'); + vi.resetModules(); + const { useEmailStore } = await import('@/stores/email-store'); + const { useAuthStore } = await import('@/stores/auth-store'); + const rows = [{ id: 'stale', mailboxIds: { inbox: true } }]; + let answer: (value: unknown) => void = () => {}; + const local = { + getAccountId: () => 'acc', + getEmails: vi.fn(() => new Promise((resolve) => { answer = resolve; })), + getThreadEmailCounts: vi.fn(async () => ({})), + }; + useAuthStore.setState({ client: local as never, activeAccountId: 'alice@mail.example' }); + useEmailStore.setState({ mailboxes: MAILBOXES as never, selectedMailbox: 'inbox', emails: [] }); + const fetching = useEmailStore.getState().fetchEmails(local as never, 'inbox'); + await vi.waitFor(() => expect(local.getEmails).toHaveBeenCalled()); + useAuthStore.setState({ client: { getAccountId: () => 'acc' } as never }); + answer({ emails: rows, hasMore: false, total: 1, state: 's1' }); + await fetching; + expect(useEmailStore.getState().emails).toEqual([]); }); }); diff --git a/stores/account-store.ts b/stores/account-store.ts index 87207fc8d..836dada92 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -1,7 +1,11 @@ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +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'; +import { clearEvicted, markEvicted } from '@/lib/offline/state'; export type AccountRemovalReason = 'explicit' | 'evicted'; @@ -13,6 +17,14 @@ export function onAccountRemoved(listener: (accountId: string, reason: AccountRe return () => removalListeners.delete(listener); } +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; @@ -111,6 +123,7 @@ export const useAccountStore = create<AccountState>()( const state = get(); const id = generateAccountId(entry.username, entry.serverUrl); + clearEvicted(id); if (state.accounts.some((a) => a.id === id)) { // Already exists - update mutable fields and return existing id set((s) => ({ @@ -163,6 +176,8 @@ export const useAccountStore = create<AccountState>()( removeAccount: (accountId, reason = 'explicit') => { if (!get().accounts.some((a) => a.id === accountId)) return; + const leaving = get().accounts.find((a) => a.id === accountId); + if (reason === 'evicted') markEvicted(accountId); for (const listener of [...removalListeners]) listener(accountId, reason); set((s) => { const remaining = s.accounts.filter((a) => a.id !== accountId); @@ -188,6 +203,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) => { @@ -265,6 +284,7 @@ export const useAccountStore = create<AccountState>()( }), { name: 'account-registry', + storage: createJSONStorage(mainWindowStorage), partialize: (state) => ({ accounts: state.accounts, activeAccountId: state.activeAccountId, diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 9f2cc5608..9fc0c7ca3 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +import { createJSONStorage, persist } from 'zustand/middleware'; import { JMAPClient, RateLimitError, RequestTimeoutError } from '@/lib/jmap/client'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { useIdentityStore } from './identity-store'; @@ -16,10 +16,13 @@ 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'; -import { broadcastSignOut, onSignedOutElsewhere, passSignOutGates, purgeSignedOutData, runBeforeSignOut } from '@/lib/sign-out-cleanup'; +import { broadcastSignOut, onSignedOutElsewhere, passSignOutGates, purgeSignedOutData, runBeforeSignOut, type SignOutCleanupOptions } from '@/lib/sign-out-cleanup'; import { forgetPrincipals } from '@/lib/delegation/known-principals'; import { useSearchHistoryStore } from './search-history-store'; import { useCalendarNotificationStore } from './calendar-notification-store'; @@ -30,8 +33,11 @@ import { SESSION_TOO_OLD } from '@/lib/session-policy/constants'; import type { Identity } from '@/lib/jmap/types'; import { authHooks } from '@/lib/plugin-hooks'; import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite'; +import { withTokenLock } from '@/lib/auth/token-lock'; +import { requestWindowSession, type SharedAccountSession } from '@/lib/popout/channel'; +import { closePopoutWindow, isPopoutWindow, mainWindowStorage } from '@/lib/popout/window-role'; import { noteCredentialsRejected } from '@/lib/offline/credentials-rejected'; -import { useOfflineStore } from '@/lib/offline/state'; +import { readEvicted, useOfflineStore } from '@/lib/offline/state'; import { toAsciiEmail } from '@/lib/idn'; import { flushComposers } from '@/lib/compose-flush-registry'; import { sortIdentities } from '@/lib/identity-order'; @@ -121,6 +127,12 @@ interface AuthState { removeAccount: (accountId: string, reason?: AccountRemovalReason) => void; switchAccount: (accountId: string) => Promise<void>; checkAuth: () => Promise<void>; + /** + * Start-up of a pop-out window, in place of checkAuth: takes the session + * from a main window, or from the remembered cookies when none is open. + * Never evicts an account and starts no token timers. + */ + startSharedSession: (options?: { accountId?: string }) => Promise<'ready' | 'no-session'>; clearError: () => void; syncIdentities: () => void; /** @@ -154,6 +166,12 @@ export interface LogoutOptions { * provider, and signing in again should stay a single click. */ endProviderSession?: boolean; + /** + * The app, not the user, ends this login, and nothing says the login itself + * is over (an outage, an unknown refusal): local mail and the outbox stay, + * hidden, for the next sign-in or 30 days. + */ + keepLocalData?: boolean; } const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [ @@ -472,6 +490,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)); } @@ -606,21 +626,30 @@ async function noteSessionAnswer(res: Response, slot: number | undefined): Promi * access token. `remember: false` keeps a rotated refresh token in a cookie * that ends with the browser session. */ -async function fetchSlotAccessToken(slot: number, opts: { force?: boolean; remember?: boolean } = {}): Promise<Response> { +function fetchSlotAccessToken(slot: number, opts: { force?: boolean; remember?: boolean } = {}): Promise<Response> { // Renewing would write the tokens of a slot being signed out back. - if (closingSlots.has(slot)) return jsonResponse({ error: 'signed_out' }, 401); - if (!IS_LITE) { - const query = `${opts.force ? '&force=true' : ''}${opts.remember === false ? '&remember=false' : ''}`; - return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${query}`, { method: 'PUT', signal })) - .then((res) => noteSessionAnswer(res, slot)); - } - // Like the route's cookie cache: a restore resumes with the token it had, - // since a renewal before the refresh token's `nbf` is refused (#552). - if (!opts.force) { - const cached = readLiteAccessToken(slot); - if (cached) return jsonResponse({ access_token: cached.accessToken, expires_in: cached.expiresIn }); - } - return trackSessionWrite(slot, async () => { + if (closingSlots.has(slot)) return Promise.resolve(jsonResponse({ error: 'signed_out' }, 401)); + // Tracked from before the wait, so a sign-out can call off a renewal still + // queued on the lock. + return trackSessionWrite(slot, (signal) => withTokenLock(slot, async ({ contended }) => { + // The slot may have started closing during the wait. + if (closingSlots.has(slot)) return jsonResponse({ error: 'signed_out' }, 401); + // One at a time across windows: each must send the refresh token the last + // answer wrote (lib/auth/token-lock.ts). After waiting for another window, + // what it renewed is what this one wanted: forcing a second renewal this + // soon can be refused (Rauthy `nbf`, #552). + const force = opts.force && !contended; + if (!IS_LITE) { + const query = `${force ? '&force=true' : ''}${opts.remember === false ? '&remember=false' : ''}`; + return apiFetch(`/api/auth/token?slot=${slot}${query}`, { method: 'PUT', signal }) + .then((res) => noteSessionAnswer(res, slot)); + } + // Like the route's cookie cache: a restore resumes with the token it had, + // since a renewal before the refresh token's `nbf` is refused (#552). + if (!force) { + const cached = readLiteAccessToken(slot); + if (cached) return jsonResponse({ access_token: cached.accessToken, expires_in: cached.expiresIn }); + } try { const tokens = await liteRefreshTokens(slot); return jsonResponse({ access_token: tokens.accessToken, expires_in: tokens.expiresIn }); @@ -628,7 +657,7 @@ async function fetchSlotAccessToken(slot: number, opts: { force?: boolean; remem if (err instanceof LiteLoginError) return jsonResponse({ error: err.code }, liteErrorStatus(err)); throw err; // network failure - callers treat it as an outage, not a rejection } - }); + }, { signal })); } /** PUT /api/auth/session[?slot=N] - the slot's remembered password or access token. */ @@ -952,6 +981,11 @@ export function redirectToLogin(): void { function navigateToLogin(): void { if (typeof window === 'undefined') return; + // Signing in happens in the main window; a pop-out just goes away. + if (isPopoutWindow()) { + closePopoutWindow(); + return; + } const loginPath = getLocaleLoginPath(); if (window.location.pathname === loginPath) return; @@ -1028,6 +1062,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; @@ -1119,6 +1161,50 @@ function snapshotAndClearForSwitch(activeAccountId: string | null): void { const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>(); const refreshPromises = new Map<string, Promise<string | null>>(); +/** Told when a pop-out's session can't be renewed any more and no main window is left to do it. */ +export const SHARED_SESSION_ENDED_EVENT = 'bulwark:shared-session-ended'; + +function bearerToken(header: string): string | null { + return header.startsWith('Bearer ') ? header.slice('Bearer '.length) : null; +} + +/** + * A new token for a pop-out, whose credential the server just refused. It asks + * the main window first: that one owns the renewal, and usually already has + * the next token. Only with no main window left does the pop-out renew, under + * the same lock (fetchSlotAccessToken), and it never signs anyone out. + */ +async function renewSharedToken(accountId: string): Promise<string | null> { + const client = clients.get(accountId); + const account = useAccountStore.getState().getAccountById(accountId); + if (!client || !account) return null; + const rejected = client.getAuthHeader(); + try { + const answer = await requestWindowSession({ accountId, rejectedAuthHeader: rejected }); + const shared = answer?.sessions.find((session) => session.accountId === accountId); + if (shared) return shared.authHeader === rejected ? null : bearerToken(shared.authHeader); + + let res = await fetchSlotAccessToken(account.cookieSlot, { remember: account.rememberMe }); + if (res.ok) { + let { access_token } = await res.json(); + // The cached token is the one the server refused. + if (`Bearer ${access_token}` === rejected) { + res = await fetchSlotAccessToken(account.cookieSlot, { force: true, remember: account.rememberMe }); + if (res.ok) ({ access_token } = await res.json()); + } + if (res.ok) { + await syncStalwartAuthContext(account.serverUrl, account.username, `Bearer ${access_token}`, account.cookieSlot); + return access_token; + } + } + if (res.status === 401 && typeof window !== 'undefined') window.dispatchEvent(new Event(SHARED_SESSION_ENDED_EVENT)); + return null; + } catch (error) { + debug.error('Token renewal in a pop-out failed:', error); + return null; + } +} + // Retry backoff for transiently failed token refreshes (#588). The values // are pseudo-expiries for scheduleRefresh - its "expiry - 60s" math turns // them into delays of 30s, 1m, 2m and 5m (capped). Consecutive failures @@ -1163,6 +1249,20 @@ function recordPermanentRefreshFailure(status: number, accountId?: string): bool return true; } +/** + * Whether a refused renewal ends the login for real, which wipes the account's + * local data: the server said invalid_grant, or the organisation's session limit + * was reached. Anything else, including a body we can't read, keeps the data. + */ +async function loginReallyOver(res: Response): Promise<boolean> { + try { + const { error } = await res.clone().json() as { error?: unknown }; + return error === 'invalid_grant' || error === SESSION_TOO_OLD; + } catch { + return false; + } +} + function notifySignInAgain(): void { void import('@/stores/toast-store').then(({ toast }) => { toast.error('Your session could not be renewed', 'Sign in again to continue.'); @@ -1348,7 +1448,8 @@ async function startLocalCopyClient(accountId: string, serverUrl: string, userna if (IS_LITE || skipLocalCopy || !useOfflineStore.getState().enabled.includes(accountId)) return null; try { const offline = await import('@/lib/offline/offline-mode'); - const session = await offline.offlineSessionFor(accountId); + // A store that has no folders yet has nothing to show: the snapshot stays up. + const session = await offline.offlineSessionFor(accountId, { withFolders: true }); return session ? JMAPClient.fromOfflineSession(serverUrl, username, session) : null; } catch { return null; @@ -1358,7 +1459,7 @@ async function startLocalCopyClient(accountId: string, serverUrl: string, userna // While device-only clients get replaced, restoring goes straight to the server. let skipLocalCopy = false; -function performFullLogout(set: (state: Partial<AuthState>) => void): void { +function performFullLogout(set: (state: Partial<AuthState>) => void, options: SignOutCleanupOptions = {}): void { useSettingsStore.getState().disableSync(); // With settings sync on, the local settings and templates are a copy of the // signed-out account's server file. Left in place, the next account to sign @@ -1397,7 +1498,9 @@ function performFullLogout(set: (state: Partial<AuthState>) => void): void { // doesn't re-write stale values. try { localStorage.removeItem('auth-storage'); } catch { /* noop */ } try { localStorage.removeItem('account-registry'); } catch { /* noop */ } - purgeSignedOutData(); + // A dropped account's data outlives anyone else's sign-out. The account that + // signed out takes its own along when it leaves the registry. + purgeSignedOutData({ keepLocalStore: options.keepLocalStore || Object.keys(readEvicted()).length > 0 }); // Other tabs still hold the accounts' mail in memory. broadcastSignOut(); } @@ -1418,9 +1521,9 @@ function notifySignedIn(state: { activeAccountId: string | null; username: strin void authHooks.onLogin.emit({ accountId: state.activeAccountId, username: state.username ?? '' }); } -function beforeLogoutHooks(accountId: string): Promise<void> { +function beforeLogoutHooks(accountId: string, options: SignOutCleanupOptions = {}): Promise<void> { return Promise.race([ - Promise.all([authHooks.onBeforeLogout.emit({ accountId }), runBeforeSignOut(accountId)]).then(() => {}), + Promise.all([authHooks.onBeforeLogout.emit({ accountId }), runBeforeSignOut(accountId, options)]).then(() => {}), new Promise<void>((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)), ]); } @@ -2195,9 +2298,16 @@ export const useAuthStore = create<AuthState>()( return refreshPromise; } + // A pop-out is on another window's session: it asks that window first. + if (isPopoutWindow()) { + if (!accountId) return null; + const renewal = renewSharedToken(accountId).finally(() => refreshPromises.delete(accountId)); + refreshPromises.set(accountId, renewal); + return renewal; + } + const account = accountId ? useAccountStore.getState().getAccountById(accountId) : null; const slot = account?.cookieSlot ?? 0; - const isActive = !accountId || accountId === get().activeAccountId; // The account is being signed out: a renewal now would only undo that. if (closingSlots.has(slot)) return null; @@ -2209,6 +2319,12 @@ export const useAuthStore = create<AuthState>()( // Session restore passes allowCached to reuse a still-valid token. const res = await fetchSlotAccessToken(slot, { force: !options?.allowCached, remember: account?.rememberMe }); + // Possibly a long wait for another window's renewal. A slot closing + // or an account gone by now says nothing about any session, and a + // refusal mustn't sign out whichever account is active by now. + if (closingSlots.has(slot) || (account && !useAccountStore.getState().getAccountById(account.id))) return null; + const isActive = !accountId || accountId === get().activeAccountId; + if (!res.ok) { // Only a definitive 401 ends the session. Anything else (5xx // while the server restarts, proxy errors) is an outage - keep @@ -2216,16 +2332,17 @@ export const useAuthStore = create<AuthState>()( // maintenance windows and offline spells. if (res.status === 401) { resetRefreshBackoff(accountId ?? undefined); + const over = await loginReallyOver(res); + if (over && accountId) noteCredentialsRejected(accountId); // Only this login's refresh token was refused. - if (accountId) noteCredentialsRejected(accountId); if (accountId && !isActive) { - get().removeAccount(accountId, 'evicted'); + get().removeAccount(accountId, over ? 'explicit' : 'evicted'); return null; } await saveDraftsBeforeExpiry(slot); notifyParent('sso:session-expired'); markSessionExpired(); - get().logout({ endProviderSession: false }); + get().logout({ endProviderSession: false, keepLocalData: !over }); return null; } // A Bulwark-side failure (500/502) that keeps repeating will not @@ -2240,7 +2357,8 @@ export const useAuthStore = create<AuthState>()( if (accountId && accountId !== get().activeAccountId) get().removeAccount(accountId, 'evicted'); else { await saveDraftsBeforeExpiry(slot); - get().logout({ endProviderSession: false }); + // An outage on our side says nothing against the mail on this device. + get().logout({ endProviderSession: false, keepLocalData: true }); } return null; } @@ -2310,13 +2428,14 @@ export const useAuthStore = create<AuthState>()( const slot = account?.cookieSlot ?? 0; // `options` can be a click event when the action is wired straight to onClick. const userInitiated = options?.endProviderSession !== false; + const keepLocalData = options?.keepLocalData === true; const endProviderSession = userInitiated && !wasDemoMode && shouldEndProviderSession(account); // Signing out on purpose may be stopped by the user (unsent mail in // the offline outbox would be lost); an automatic one may not. if (userInitiated && !(await passSignOutGates(accountId ?? 'all'))) return; - await beforeLogoutHooks(accountId ?? 'all'); + await beforeLogoutHooks(accountId ?? 'all', { keepLocalStore: keepLocalData }); // From here on nothing may renew this slot's session; the cleanup // below reopens it once the credentials are gone. @@ -2334,7 +2453,7 @@ export const useAuthStore = create<AuthState>()( if (accountId) { clients.delete(accountId); evictAccount(accountId); - accountStore.removeAccount(accountId); + accountStore.removeAccount(accountId, keepLocalData ? 'evicted' : 'explicit'); forgetPrincipals(accountId); if (!wasDemoMode) clearPluginStorageForAccount(accountId); } @@ -2428,7 +2547,9 @@ export const useAuthStore = create<AuthState>()( clients.get(remaining.id)?.disconnect(); clients.delete(remaining.id); evictAccount(remaining.id); - accountStore.removeAccount(remaining.id); + // Dropped by this sign-out, not signed out by anyone: their unsent + // mail never passed a question, so it waits for their next sign-in. + accountStore.removeAccount(remaining.id, 'evicted'); } } @@ -2442,7 +2563,7 @@ export const useAuthStore = create<AuthState>()( ...droppedAccounts.map((dropped) => clearSlotCredentials(dropped.cookieSlot ?? 0, dropped.authMode === 'oauth')), ]); - performFullLogout(set); + performFullLogout(set, { keepLocalStore: keepLocalData }); notifyParent('sso:logout'); @@ -2845,7 +2966,8 @@ export const useAuthStore = create<AuthState>()( if (!account.rememberMe && !IS_LITE) { forgottenAccounts += 1; evictAccount(account.id); - accountStore.removeAccount(account.id, 'evicted'); + // The user's choice on what may be a shared computer: its local data goes too. + accountStore.removeAccount(account.id, 'explicit'); if (account.authMode === 'oauth') void clearSlotCredentials(account.cookieSlot, true); return; } @@ -2870,7 +2992,7 @@ export const useAuthStore = create<AuthState>()( // Repeated 500/502 (see recordPermanentRefreshFailure) falls // through here and evicts the account like a rejection. (#972) if (res.status >= 500) notifySignInAgain(); - throw new Error(`Token refresh failed: ${res.status}`); + throw Object.assign(new Error(`Token refresh failed: ${res.status}`), { loginOver: await loginReallyOver(res) }); } } else { const res = await fetchSlotSession(account.cookieSlot); @@ -2888,7 +3010,7 @@ export const useAuthStore = create<AuthState>()( impersonated: session.impersonated === true, }); void syncAccountDisplayName(account.id, client); - } else if (res.status >= 500) { + } else if (res.status >= 500 || res.status === 429) { throw new TransientAuthError('Session restore failed', res.status); } else { throw new Error(`Session cookie missing: ${res.status}`); @@ -2927,9 +3049,10 @@ export const useAuthStore = create<AuthState>()( } // Remove unrestorable accounts so the user is prompted to log in // again rather than seeing a stale error entry forever. Its - // refresh token goes with it: nothing would use or revoke it. + // refresh token goes with it: nothing would use or revoke it. Its + // local data waits, unless the server really ended the login. evictAccount(account.id); - accountStore.removeAccount(account.id, 'evicted'); + accountStore.removeAccount(account.id, (err as { loginOver?: boolean }).loginOver ? 'explicit' : 'evicted'); void clearSlotCredentials(account.cookieSlot, account.authMode === 'oauth'); } }; @@ -2965,6 +3088,9 @@ export const useAuthStore = create<AuthState>()( if (!real) { if (useAccountStore.getState().getAccountById(entry.id)) { clients.set(entry.id, local); + // A 5xx or a rate limit, with the server up otherwise. The app stays + // on the local copy, and says so, and leaves it when the server answers. + void import('@/lib/offline/offline-mode').then((offline) => offline.markStartedOffline()).catch(() => {}); return; } if (get().activeAccountId !== entry.id) return; @@ -2977,6 +3103,15 @@ export const useAuthStore = create<AuthState>()( if (get().activeAccountId !== entry.id) return; initializeFeatureStores(real); set({ client: real, ...getClientRateLimitState(real), connectionLost: false, error: null }); + // The local copy may have had none yet, and the server's are newer. + void real.getIdentities() + .then((raw) => { + if (get().client !== real) return; + const { identities, primaryIdentity } = loadIdentities(raw, entry.username); + set({ identities, primaryIdentity }); + applyPreferredIdentity(entry.id); + }) + .catch((err) => debug.error('Failed to load identities after the local copy:', err)); }; // A few at a time rather than one after the other: with many logins @@ -3021,6 +3156,8 @@ export const useAuthStore = create<AuthState>()( // spending a serial round trip before the UI unblocks. const identitiesLoaded = targetClient.getIdentities() .then((raw) => { + // The real client took over meanwhile, and asks for itself. + if (localCopy && get().client !== null && get().client !== targetClient) return; const { identities, primaryIdentity } = loadIdentities(raw, targetAccount.username); set({ identities, primaryIdentity }); }) @@ -3346,6 +3483,105 @@ export const useAuthStore = create<AuthState>()( } }, + startSharedSession: async (options) => { + set({ isLoading: true, client: null }); + const accountStore = useAccountStore.getState(); + const answer = await requestWindowSession(options?.accountId ? { accountId: options.accountId } : {}); + const sessions = answer?.sessions ?? []; + + // No main window open: what the cookies still hold. A login made + // without "Remember me" has nothing there, and that is no reason to + // evict it: the list is the main window's business. + const restoreFromCookies = async (account: AccountEntry): Promise<JMAPClient | null> => { + if (account.awaitingSignIn) return null; + if (account.authMode === 'oauth') { + const res = await fetchSlotAccessToken(account.cookieSlot, { remember: account.rememberMe }); + if (res.status >= 500) throw new TransientAuthError('Token refresh failed', res.status); + if (!res.ok) return null; + const { access_token } = await res.json(); + await syncStalwartAuthContext(account.serverUrl, account.username, `Bearer ${access_token}`, account.cookieSlot); + return JMAPClient.withBearer(account.serverUrl, access_token, account.username, () => get().refreshAccessToken({ accountId: account.id })); + } + if (!account.rememberMe && !IS_LITE) return null; + const res = await fetchSlotSession(account.cookieSlot); + if (res.status >= 500) throw new TransientAuthError('Session restore failed', res.status); + if (!res.ok) return null; + const session: RememberedSession = await res.json(); + return clientForSession(session); + }; + + const adopt = async (accountId: string, session?: SharedAccountSession): Promise<JMAPClient | null> => { + const account = accountStore.getAccountById(accountId); + if (!account) return null; + try { + if (session?.offlineOnly) throw new TransientAuthError('The main window is offline', 503); + const client = session + ? JMAPClient.withSharedAuth(account.serverUrl, account.username, session.authHeader, session.authMode === 'oauth' + ? () => get().refreshAccessToken({ accountId }) + : undefined) + : await restoreFromCookies(account); + if (!client) return null; + bindClientStatusHandlers(client, set, get, accountId); + await client.connect(); + clients.set(accountId, client); + accountStore.updateAccount(accountId, { isConnected: true, hasError: false }); + return client; + } catch (error) { + debug.error(`A pop-out could not use account ${accountId}:`, error); + if (!isTransientAuthError(error)) return null; + const offlineClient = await startOfflineClient(accountId, account.serverUrl, account.username); + if (offlineClient) clients.set(accountId, offlineClient); + return offlineClient; + } + }; + + const targetId = options?.accountId + ?? answer?.activeAccountId + ?? accountStore.activeAccountId + ?? accountStore.getDefaultAccount()?.id + ?? null; + const account = targetId ? accountStore.getAccountById(targetId) : undefined; + const client = account ? await adopt(account.id, sessions.find((session) => session.accountId === account.id)) : null; + if (!account || !client) { + set({ isLoading: false, isAuthenticated: false, client: null }); + return 'no-session'; + } + + // The window's other logins: a draft may live in one (see + // ComposerDraftData), and the Pro From picker lists them. + const others = sessions.filter((session) => session.accountId !== account.id); + if (others.length > 0) { + void Promise.allSettled(others.map((session) => adopt(session.accountId, session))).then(() => { + set((state) => ({ connectedAccountsRevision: state.connectedAccountsRevision + 1 })); + }); + } + + accountStore.setActiveAccount(account.id); + initializeFeatureStores(client); + let loaded: ReturnType<typeof loadIdentities> = { identities: [], primaryIdentity: null }; + try { + loaded = loadIdentities(await client.getIdentities(), account.username); + } catch (error) { + debug.error('Failed to load identities in a pop-out:', error); + } + set({ + isAuthenticated: true, + isLoading: false, + serverUrl: account.serverUrl, + username: account.username, + client, + ...getClientRateLimitState(client), + identities: loaded.identities, + primaryIdentity: loaded.primaryIdentity, + authMode: account.authMode, + rememberMe: account.rememberMe, + connectionLost: false, + error: null, + activeAccountId: account.id, + }); + return 'ready'; + }, + getClientForAccount: (accountId: string) => { return clients.get(accountId); }, @@ -3372,6 +3608,7 @@ export const useAuthStore = create<AuthState>()( }), { name: 'auth-storage', + storage: createJSONStorage(mainWindowStorage), partialize: (state) => { // Don't persist unauthenticated state - prevents resurrecting stale sessions if (!state.isAuthenticated) return {}; @@ -3438,6 +3675,8 @@ useAuthStore.subscribe((state) => { // gone too, so leave for the login page instead of showing stale mail. if (typeof window !== 'undefined') { onSignedOutElsewhere(() => { + // A pop-out saves its typing first (components/popout/popout-shell.tsx). + if (isPopoutWindow()) return; if (useAuthStore.getState().isAuthenticated) navigateToLogin(); }); } diff --git a/stores/email-store.ts b/stores/email-store.ts index a917bcfe0..8bff6f55d 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1627,10 +1627,13 @@ function threadCountsPatch( function captureEmailListView(): () => boolean { const view = useEmailStore.getState(); const activeAccountId = useAuthStore.getState().activeAccountId; + // A local copy's late answer must not overwrite what the real client loaded. + const authClient = useAuthStore.getState().client; const epoch = currentStoreEpoch(); return () => { const current = useEmailStore.getState(); return currentStoreEpoch() === epoch + && useAuthStore.getState().client === authClient && current.selectedMailbox === view.selectedMailbox && current.selectedKeyword === view.selectedKeyword && current.viewingAccountId === view.viewingAccountId @@ -8630,15 +8633,6 @@ const EMAIL_SNAPSHOT_KEY = 'email-snapshot'; const EMAIL_SNAPSHOT_VERSION = 3; const EMAIL_SNAPSHOT_MAX_EMAILS = 50; -/** - * With the local store on, the mail is encrypted on this device, and a copy of - * subjects in localStorage would give that away. The store paints the list at - * start itself (startLocalCopyClient), so the snapshot isn't needed there. - */ -function keptEncrypted(account: unknown): boolean { - return typeof account === 'string' && useOfflineStore.getState().enabled.includes(account); -} - function isEmailListSync(value: unknown, mailboxId: string): value is EmailListSync { const sync = value as EmailListSync | null; return !!sync && typeof sync.state === 'string' && typeof sync.accountId === 'string' && sync.mailboxId === mailboxId; @@ -8661,7 +8655,6 @@ if (typeof window !== 'undefined') { try { const auth = persistedAuthState(); const authed = auth?.isAuthenticated === true; - if (keptEncrypted(auth?.activeAccountId)) window.localStorage.removeItem(EMAIL_SNAPSHOT_KEY); const raw = authed ? window.localStorage.getItem(EMAIL_SNAPSHOT_KEY) : null; if (raw) { const snap = JSON.parse(raw); @@ -8709,12 +8702,6 @@ if (typeof window !== 'undefined') { } catch { /* corrupt snapshot - start empty */ } let snapshotTimer: ReturnType<typeof setTimeout> | null = null; - // Turned on just now: what's there goes at once. - useOfflineStore.subscribe((state, prev) => { - if (state.enabled === prev.enabled || !keptEncrypted(persistedAuthState()?.activeAccountId)) return; - if (snapshotTimer) { clearTimeout(snapshotTimer); snapshotTimer = null; } - try { window.localStorage.removeItem(EMAIL_SNAPSHOT_KEY); } catch { /* ignore */ } - }); useEmailStore.subscribe((state, prev) => { if ( state.emails === prev.emails && @@ -8744,7 +8731,9 @@ if (typeof window !== 'undefined') { // would be read as the active account's mail. (#847) if (s.searchQuery || s.selectedKeyword || s.isScheduledView || s.viewingAccountId || s.isUnifiedView) return; const account = persistedAuthState()?.activeAccountId; - if (typeof account !== 'string' || !account || keptEncrypted(account)) return; + if (typeof account !== 'string' || !account) return; + // Its mail lives encrypted in the store, and not in plain localStorage too. + if (useOfflineStore.getState().enabled.includes(account)) return; try { window.localStorage.setItem(EMAIL_SNAPSHOT_KEY, JSON.stringify({ v: EMAIL_SNAPSHOT_VERSION,