From a30f989c578eee8015d9340d773bd367ae991cd8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:32:27 +0100 Subject: [PATCH 01/27] feat: add a desktop build of the webmail that no server build can switch on --- .../settings/account-security-settings.tsx | 11 +- lib/__tests__/standalone-guard.test.ts | 27 ++++ lib/account-utils.ts | 5 + lib/admin/jmap-servers.ts | 6 + lib/desktop/__tests__/trust-flag.test.ts | 112 +++++++++++++++++ lib/desktop/blocked-routes.ts | 10 ++ lib/desktop/bridge.ts | 20 +++ lib/desktop/flag.ts | 17 +++ lib/desktop/trusted-servers.ts | 33 +++++ lib/desktop/types.ts | 117 ++++++++++++++++++ lib/theme-color-meta.ts | 12 ++ lib/web-push.ts | 3 + next.config.ts | 4 + package.json | 1 + proxy.ts | 6 + scripts/build-desktop.mjs | 22 ++++ scripts/desktop-build-flag.mjs | 12 ++ scripts/standalone-guard.mjs | 27 +++- 18 files changed, 440 insertions(+), 5 deletions(-) create mode 100644 lib/desktop/__tests__/trust-flag.test.ts create mode 100644 lib/desktop/blocked-routes.ts create mode 100644 lib/desktop/bridge.ts create mode 100644 lib/desktop/flag.ts create mode 100644 lib/desktop/trusted-servers.ts create mode 100644 lib/desktop/types.ts create mode 100644 scripts/build-desktop.mjs create mode 100644 scripts/desktop-build-flag.mjs 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() { {!IS_LITE ? (
- -
+ {!IS_DESKTOP_BUILD && ( + <> + +
+ + )}
) : ( @@ -1610,7 +1615,7 @@ export function AccountSecuritySettings() { description={withoutPassword ? t('description') : t('sections.devices_description')} >
- {!IS_LITE && ( + {!IS_LITE && !IS_DESKTOP_BUILD && ( <>
diff --git a/lib/__tests__/standalone-guard.test.ts b/lib/__tests__/standalone-guard.test.ts index dbc69e792..f982ecb14 100644 --- a/lib/__tests__/standalone-guard.test.ts +++ b/lib/__tests__/standalone-guard.test.ts @@ -31,6 +31,33 @@ 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('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/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..fa054875a 100644 --- a/lib/admin/jmap-servers.ts +++ b/lib/admin/jmap-servers.ts @@ -1,6 +1,9 @@ /** * Multi-server JMAP support: schema, parsing, lookup, and redaction helpers. */ +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { isDesktopTrustedServer } from '@/lib/desktop/trusted-servers'; + export interface JmapServerOAuthConfig { clientId?: string; @@ -223,6 +226,9 @@ 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. Compiled out of every other + // build, so nothing at runtime can open this path on a server. + if (IS_DESKTOP_BUILD && 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/desktop/__tests__/trust-flag.test.ts b/lib/desktop/__tests__/trust-flag.test.ts new file mode 100644 index 000000000..202a4b1ff --- /dev/null +++ b/lib/desktop/__tests__/trust-flag.test.ts @@ -0,0 +1,112 @@ +// @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 | 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: NodeJS.ProcessEnv, runtimeEnv: NodeJS.ProcessEnv): Promise { + 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)) }, + }); + 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({})).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('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/bridge.ts b/lib/desktop/bridge.ts new file mode 100644 index 000000000..852ebbfbe --- /dev/null +++ b/lib/desktop/bridge.ts @@ -0,0 +1,20 @@ +/** + * 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; +} + +/** 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/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/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; + +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; + 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..7ae919aa6 --- /dev/null +++ b/lib/desktop/types.ts @@ -0,0 +1,117 @@ +/** + * The desktop app's preload bridge as the webmail sees it. Feature checks ask + * `window.bulwarkDesktop?.`; 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; + /** 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) => 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; + /** The origins a session names, for the CORS headers the shell adds for them. */ + noteSession: ( + serverUrl: string, + urls: { apiUrl?: string; downloadUrl?: string; uploadUrl?: string; eventSourceUrl?: string }, + ) => 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; + 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. The renderer never holds an admin session. Standalone only. + */ + install: (slug: string, version: string) => Promise; +} + +export interface BulwarkDesktopBridge { + version: number; + platform: 'win32' | 'darwin' | 'linux'; + mode?: 'standalone' | 'instance'; + windows?: DesktopWindowsBridge; + theme?: DesktopThemeBridge; + menu?: DesktopMenuBridge; + shell?: DesktopShellBridge; + servers?: DesktopServersBridge; + auth?: DesktopAuthBridge; + plugins?: DesktopPluginsBridge; +} + +declare global { + interface Window { + bulwarkDesktop?: BulwarkDesktopBridge; + } +} 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 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('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 . */ 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/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/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..a6f52d122 --- /dev/null +++ b/scripts/desktop-build-flag.mjs @@ -0,0 +1,12 @@ +// 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'; + +export function desktopBuildFlag(env = process.env) { + return env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' ? '1' : ''; +} diff --git a/scripts/standalone-guard.mjs b/scripts/standalone-guard.mjs index 80aaff1de..d44165b9e 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_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,32 @@ 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') && readFileSync(full, 'utf8').includes(DESKTOP_TRUST_MARKER)) { + 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) { From e65f7fe31fd83741dc58a45fa656e7320365e642 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:32:28 +0100 Subject: [PATCH 02/27] feat: let the desktop app reach LAN and plain http mail servers the user approved --- app/api/auth/session/route.ts | 2 +- app/api/auth/stalwart-context/route.ts | 2 +- app/api/auth/verify/route.ts | 4 +- lib/auth/client-address.ts | 10 +- lib/desktop/__tests__/login-routes.test.ts | 147 +++++++++++++++++++++ lib/desktop/jmap-route.ts | 42 ++++++ lib/desktop/login-budget.ts | 17 +++ lib/jmap/client.ts | 23 +++- lib/offline/offline-mode.ts | 3 +- 9 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 lib/desktop/__tests__/login-routes.test.ts create mode 100644 lib/desktop/jmap-route.ts create mode 100644 lib/desktop/login-budget.ts 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/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/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 ({ + 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 = { 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()), + resolveJmapIdentity, +})); + +const reserveLoginAttempt = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/auth/verify-budget', async (original) => ({ + ...(await original()), + 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[0])) as unknown as { + status: number; + json: () => Promise>; + }; + 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>)[TRUST_KEY] = new Set([LAN_SERVER]); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + delete (globalThis as unknown as Record)[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>)[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/jmap-route.ts b/lib/desktop/jmap-route.ts new file mode 100644 index 000000000..491b2b4e3 --- /dev/null +++ b/lib/desktop/jmap-route.ts @@ -0,0 +1,42 @@ +/** + * 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 http://192.168.x as + * mixed content, whatever the CSP says. The embedded server forwards + * `/__jmap//...` 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; + } +} + +/** 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/jmap/client.ts b/lib/jmap/client.ts index 6d35dcdfa..8554ae898 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 } 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"; @@ -1887,7 +1889,7 @@ export class JMAPClient implements IJMAPClient { const retryHeaders = { ...init?.headers as Record, '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 { @@ -1937,7 +1939,7 @@ export class JMAPClient implements IJMAPClient { * and retry directly against the final URL so we can re-send Authorization. */ private async fetchSessionResponse(): Promise { - 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 @@ -1980,7 +1982,7 @@ export class JMAPClient implements IJMAPClient { async connect(): Promise { 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(); @@ -2137,10 +2139,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); } @@ -2173,6 +2180,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, + }); } /** diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 0152db852..c9badd35e 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -20,6 +20,7 @@ */ import { IS_LITE } from '@/lib/lite'; +import { desktopJmapBase } from '@/lib/desktop/jmap-route'; import { devicesForAccount } from '@/lib/device-policy'; import { accountSubjectPending, useAccountSubjects } from '@/lib/org/account-subjects'; import { labelsPolicyFor } from '@/lib/outgoing/classification'; @@ -324,7 +325,7 @@ export function probeServer(serverUrl: string): Promise { if (running) return running; const probe = (async () => { try { - await fetch(`${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit' }); + await fetch(`${desktopJmapBase(serverUrl.replace(/\/+$/, ''))}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit' }); if (setServerDown(serverUrl, false)) scheduleFlushAll(500); return false; } catch { From aa511e5d38aed30ef026cd5ef46543460ad4ae98 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:32:43 +0100 Subject: [PATCH 03/27] feat: sign in through the system browser in the desktop app, and refresh at the account's own server --- app/(main)/[locale]/auth/callback/page.tsx | 13 +- app/(main)/[locale]/login/page.tsx | 62 +++++ app/api/auth/desktop-oauth/route.ts | 86 +++++++ app/api/auth/token/route.ts | 46 +++- app/api/auth/totp-token-exchange/route.ts | 17 +- lib/auth/crypto.ts | 5 +- lib/desktop/__tests__/browser-side.test.ts | 233 ++++++++++++++++++ lib/desktop/__tests__/custom-upstream.test.ts | 231 +++++++++++++++++ lib/desktop/browser-sign-in.ts | 104 ++++++++ lib/oauth/custom-upstream.ts | 72 ++++++ lib/oauth/token-exchange.ts | 21 +- locales/ar/common.json | 9 + locales/ca/common.json | 9 + locales/cs/common.json | 9 + locales/da/common.json | 9 + locales/de/common.json | 9 + locales/en/common.json | 9 + locales/es/common.json | 9 + locales/et/common.json | 9 + locales/fa/common.json | 9 + locales/fr/common.json | 9 + locales/he/common.json | 9 + locales/hu/common.json | 9 + locales/it/common.json | 9 + locales/ja/common.json | 9 + locales/ko/common.json | 9 + locales/lv/common.json | 9 + locales/mn/common.json | 9 + locales/nb/common.json | 9 + locales/nl/common.json | 9 + locales/pl/common.json | 9 + locales/pt/common.json | 9 + locales/ro/common.json | 9 + locales/ru/common.json | 9 + locales/sk/common.json | 9 + locales/th/common.json | 9 + locales/tr/common.json | 9 + locales/uk/common.json | 9 + locales/vi/common.json | 9 + locales/zh-TW/common.json | 9 + locales/zh/common.json | 9 + stores/auth-store.ts | 13 + 42 files changed, 1153 insertions(+), 20 deletions(-) create mode 100644 app/api/auth/desktop-oauth/route.ts create mode 100644 lib/desktop/__tests__/browser-side.test.ts create mode 100644 lib/desktop/__tests__/custom-upstream.test.ts create mode 100644 lib/desktop/browser-sign-in.ts create mode 100644 lib/oauth/custom-upstream.ts 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]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 1ef9932e0..d28ae4039 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 { 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,33 @@ function LoginPageContent() { window.location.href = authUrl.toString(); }; + /** The desktop build: the server's own sign-in page in the system browser. */ + const handleDesktopBrowserSignIn = async () => { + 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); + 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,6 +1026,16 @@ 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('/'); @@ -1814,6 +1858,24 @@ function LoginPageContent() { )} + {IS_DESKTOP_BUILD && !hasServerList && browserSignInAvailable() && ( + + )} + {IS_DESKTOP_BUILD && desktopNotice && ( +

+ {desktopNotice} +

+ )} {((serverOauthEnabled && oauthDiscoveryDone && !oauthMetadata) || liteOAuthFailed) && (
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/token/route.ts b/app/api/auth/token/route.ts index 6c065e524..3e6a90667 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) { @@ -257,12 +273,13 @@ export async function PUT(request: NextRequest) { // The refresh token may have been minted by the password+TOTP login route, // which works without a configured OAuth client by falling back to the // default client id - refreshing must fall back the same way (#873). - const tokenEndpoint = await getTokenEndpoint(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }); + const upstream = IS_DESKTOP_BUILD ? await readCustomUpstream(cookieStore, slot) : null; + const tokenEndpoint = await getTokenEndpoint(serverId, upstream ? { upstream } : { fallbackClientId: DEFAULT_CLIENT_ID }); const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken, - }, serverId, await defaultClientOptions(serverId)); + }, serverId, await defaultClientOptions(serverId, upstream)); const tokenResponse = await observeUpstream('oauth', () => fetch(tokenEndpoint, { method: 'POST', @@ -283,6 +300,7 @@ export async function PUT(request: NextRequest) { cookieStore.delete(cookieName); cookieStore.delete(refreshTokenServerCookieName(slot)); cookieStore.delete(accessTokenCookieName(slot)); + clearCustomUpstream(cookieStore, slot); clearIdToken(cookieStore, slot, request.nextUrl.basePath); return NextResponse.json({ error: 'Refresh failed' }, { status: 401 }); } @@ -324,6 +342,10 @@ export async function PUT(request: NextRequest) { }); } catch (error) { logger.error('Token refresh error', { error: error instanceof Error ? error.message : 'Unknown error' }); + // On the desktop this route runs on the user's machine, so an unreachable + // mail server lands here. The client signs out after repeated 500s; a 503 + // tells it to keep the session and try again. + if (IS_DESKTOP_BUILD) return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } @@ -343,19 +365,20 @@ async function signOutSlot( const refreshToken = cookieStore.get(cookieName)?.value; const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null; const idToken = cookieStore.get(idTokenCookieName(slot))?.value; + const upstream = IS_DESKTOP_BUILD ? await readCustomUpstream(cookieStore, slot) : null; // With revocation off (#1388) the token is only dropped from the browser. const tokenToRevoke = refreshToken && isRevokeOnSignOutEnabled() ? refreshToken : undefined; let endSessionUrl: string | null = null; if (tokenToRevoke || endSession) { - const metadata = await getMetadata(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }).catch((err) => { + const metadata = await getMetadata(serverId, upstream ? { upstream } : { fallbackClientId: DEFAULT_CLIENT_ID }).catch((err) => { logger.warn('Failed to discover OAuth metadata during logout', { error: err instanceof Error ? err.message : 'Unknown error', }); return null; }); - if (tokenToRevoke) await revokeRefreshToken(tokenToRevoke, serverId, metadata); - if (endSession) endSessionUrl = endSessionUrlFor(metadata, serverId, idToken); + if (tokenToRevoke) await revokeRefreshToken(tokenToRevoke, serverId, metadata, upstream); + if (endSession) endSessionUrl = endSessionUrlFor(metadata, serverId, idToken, upstream); } // Cleared whether or not revocation worked: the browser must not be able @@ -366,6 +389,7 @@ async function signOutSlot( if (cookieStore.get(name)) cookieStore.delete(name); } clearSignInStamp(cookieStore, slot); + if (IS_DESKTOP_BUILD) clearCustomUpstream(cookieStore, slot); if (idToken !== undefined) clearIdToken(cookieStore, slot, basePath); return endSessionUrl; } diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index 4c374a4f3..46e77ac33 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,8 @@ export async function POST(request: NextRequest) { resolvedServerId = matchedEntry.id; } else if (configuredServerUrl && !customRequested) { upstreamUrl = configuredServerUrl; + } else if (IS_DESKTOP_BUILD && isDesktopTrustedServer(serverUrl)) { + upstreamUrl = serverUrl; } else if (allowCustomEndpoint) { if (!(await isPublicHttpUrl(serverUrl))) { logger.warn('TOTP login: rejected non-public server URL'); @@ -193,7 +204,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 +221,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 +239,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/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 false, 'pairing-store': () => false, 'handoff-store': () => false, + 'custom-upstream': () => false, }; export function encryptPayload(payload: Record, purpose: PayloadPurpose): string { 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 | 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..b5cd233be --- /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 = {}; +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) => 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()), + isPublicHttpUrl, +})); + +vi.mock('@/lib/stalwart/auth-context', () => ({ + clearStalwartAuthContextInStore: vi.fn(), + readStalwartAuthContextFromStore: () => null, +})); + +vi.setConfig({ testTimeout: 60_000 }); + +class FakeCookies { + store = new Map(); + 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 = {}, 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[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[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[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[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[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[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[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[0]); + expect(calls[0].url).toBe(`${CONFIGURED}/auth/token`); + }); + + it('still answers 500 on an unexpected failure', async () => { + cookieStore.set('jmap_rt', 'refresh-1'); + stubUpstream(); + const { PUT } = await loadRoute(); + const res = (await PUT(request('PUT', { force: 'true' }) as Parameters[0])) as unknown as { status: number }; + expect(res.status).toBe(500); + }); +}); 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: 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 { + 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/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>; + +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 { + if (typeof serverUrl !== 'string' || !serverUrl || typeof clientId !== 'string' || !clientId) return null; + await configManager.ensureLoaded(); + const servers = parseJmapServers(configManager.get('jmapServers', [])); + const trusted = resolveTrustedJmapUrl(serverUrl, configManager.get('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, + 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 { + const value = cookieStore.get(customUpstreamCookieName(slot))?.value; + if (!value) return null; + let payload: Record | 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('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 { +export async function defaultClientOptions(serverId?: string | null, upstream?: CustomUpstream | null): Promise { + 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, 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 { - 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/locales/ar/common.json b/locales/ar/common.json index cc8420d14..6054e416c 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6451,5 +6451,14 @@ "next_issue": "الخطأ الإملائي أو النحوي التالي", "previous_issue": "الخطأ الإملائي أو النحوي السابق" } + }, + "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 6e15f53fc..bab8e3df8 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Error d'ortografia o de gramàtica següent", "previous_issue": "Error d'ortografia o de gramàtica anterior" } + }, + "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 d8426dce2..340f13245 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Další pravopisná nebo gramatická chyba", "previous_issue": "Předchozí pravopisná nebo gramatická chyba" } + }, + "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 cf66a7294..004e7808f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Næste stave- eller grammatikfejl", "previous_issue": "Forrige stave- eller grammatikfejl" } + }, + "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 3772dbab8..29a0c852c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Nächster Rechtschreib- oder Grammatikfehler", "previous_issue": "Vorheriger Rechtschreib- oder Grammatikfehler" } + }, + "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 57a58dab4..6318dea48 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Next spelling or grammar issue", "previous_issue": "Previous spelling or grammar issue" } + }, + "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 cc786a268..e113918a6 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Siguiente error de ortografía o gramática", "previous_issue": "Anterior error de ortografía o gramática" } + }, + "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 2743f7f83..999f74069 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Järgmine õigekirja- või grammatikaviga", "previous_issue": "Eelmine õigekirja- või grammatikaviga" } + }, + "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 539f81c1a..7ca19098b 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6451,5 +6451,14 @@ "next_issue": "خطای املایی یا دستوری بعدی", "previous_issue": "خطای املایی یا دستوری قبلی" } + }, + "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 fb7d148ec..2ae64e779 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Faute d'orthographe ou de grammaire suivante", "previous_issue": "Faute d'orthographe ou de grammaire précédente" } + }, + "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 73d75426a..3065831d9 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6452,5 +6452,14 @@ "next_issue": "שגיאת האיות או הדקדוק הבאה", "previous_issue": "שגיאת האיות או הדקדוק הקודמת" } + }, + "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 b45fe495e..c208abd21 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Következő helyesírási vagy nyelvtani hiba", "previous_issue": "Előző helyesírási vagy nyelvtani hiba" } + }, + "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 a021b7397..db903bced 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Errore di ortografia o grammatica successivo", "previous_issue": "Errore di ortografia o grammatica precedente" } + }, + "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 f2ea41909..9bef272e7 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6451,5 +6451,14 @@ "next_issue": "次のスペルまたは文法の問題", "previous_issue": "前のスペルまたは文法の問題" } + }, + "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 cbde91d32..2de81935e 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6451,5 +6451,14 @@ "next_issue": "다음 맞춤법 또는 문법 문제", "previous_issue": "이전 맞춤법 또는 문법 문제" } + }, + "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 02d30c2f9..131e235fe 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Nākamā pareizrakstības vai gramatikas kļūda", "previous_issue": "Iepriekšējā pareizrakstības vai gramatikas kļūda" } + }, + "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 4dd86dea2..ae86e31d8 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Дараагийн зөв бичих эсвэл хэл зүйн алдаа", "previous_issue": "Өмнөх зөв бичих эсвэл хэл зүйн алдаа" } + }, + "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 d98bd1756..05bdf0a2c 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Neste stave- eller grammatikkfeil", "previous_issue": "Forrige stave- eller grammatikkfeil" } + }, + "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 4d541f358..f050b7b17 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Volgende spel- of grammaticafout", "previous_issue": "Vorige spel- of grammaticafout" } + }, + "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 a6a7cd520..e6dc786f5 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Następny błąd pisowni lub gramatyki", "previous_issue": "Poprzedni błąd pisowni lub gramatyki" } + }, + "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 685a98aea..512714e8a 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Próximo erro de ortografia ou gramática", "previous_issue": "Erro de ortografia ou gramática anterior" } + }, + "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 45c3fefed..85a94ec62 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Următoarea greșeală de ortografie sau gramatică", "previous_issue": "Greșeala de ortografie sau gramatică anterioară" } + }, + "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 b40069b61..d3e1b6867 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Следующая орфографическая или грамматическая ошибка", "previous_issue": "Предыдущая орфографическая или грамматическая ошибка" } + }, + "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 aa0304452..b1f985553 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Ďalšia pravopisná alebo gramatická chyba", "previous_issue": "Predchádzajúca pravopisná alebo gramatická chyba" } + }, + "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 4d2c52754..3257cdf79 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6459,5 +6459,14 @@ "next_issue": "ปัญหาการสะกดหรือไวยากรณ์ถัดไป", "previous_issue": "ปัญหาการสะกดหรือไวยากรณ์ก่อนหน้า" } + }, + "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 4f25997fa..4951f5d4e 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Sonraki yazım veya dil bilgisi hatası", "previous_issue": "Önceki yazım veya dil bilgisi hatası" } + }, + "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 408e920da..31a8a487c 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Наступна орфографічна або граматична помилка", "previous_issue": "Попередня орфографічна або граматична помилка" } + }, + "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 6952aec2e..80ed5998e 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6463,5 +6463,14 @@ "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 đó" } + }, + "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 1236173cf..fc48b4edd 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6451,5 +6451,14 @@ "next_issue": "下一個拼字或文法問題", "previous_issue": "上一個拼字或文法問題" } + }, + "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 f51d10c28..fcb0d463c 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6451,5 +6451,14 @@ "next_issue": "下一个拼写或语法问题", "previous_issue": "上一个拼写或语法问题" } + }, + "desktop_sign_in": { + "browser_button": "使用浏览器登录", + "browser_waiting": "请在浏览器中完成登录", + "browser_unavailable": "此服务器不支持通过浏览器登录。请使用密码。", + "browser_failed": "无法通过浏览器登录。请重试。", + "server_needed": "请先输入服务器地址。", + "server_declined": "你没有允许此服务器,因此没有向它发送任何内容。", + "server_invalid": "此服务器地址无效。" } } diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 380379382..58aedb417 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -16,6 +16,9 @@ import { debug } from '@/lib/debug'; import { setServerAuthIssue } from '@/lib/server-auth-status'; import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in'; import { isEmbedded, notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager'; import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope'; @@ -470,6 +473,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)); } @@ -1026,6 +1031,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; From a6df6c4aac6f781a2cb02fadd3489619df275133 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:41:22 +0100 Subject: [PATCH 04/27] docs: name no address in the desktop http route's comment --- lib/desktop/jmap-route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/desktop/jmap-route.ts b/lib/desktop/jmap-route.ts index 491b2b4e3..cac1869cb 100644 --- a/lib/desktop/jmap-route.ts +++ b/lib/desktop/jmap-route.ts @@ -1,7 +1,7 @@ /** * 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 http://192.168.x as + * 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//...` 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 From bb2393cf041fe2d02dc93d49fdab4b963b3c8428 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:50:21 +0100 Subject: [PATCH 05/27] feat: open messages and drafts in their own windows and save all attachments to a folder An installed app had no title bar of its own, no way to save attachments except a zip, and "pop out" stayed inside the page. A new window was a second copy of the app that had to restore the session itself, and on start-up that evicts any login made without "Remember me" from the list every window shares. - Title bar: `display_override` with `window-controls-overlay`, one `--titlebar-inset` variable at the page roots and skeletons, and a drag strip with the window title. 0 without an overlay, so nothing moves. - Save all to a folder: `showDirectoryPicker`, asking the device policy before the picker and before each file, with the zip as the fallback. - Pop-out routes `/popout/message/` and `/popout/compose`, on offer in an installed app or when the desktop bridge has windows. Hidden in Lite. - A pop-out borrows the main window's session and never evicts or starts a token timer. Every renewal now takes a Web Lock, so two windows can't send the same refresh token, which Stalwart 1.0 may treat as a replay. - A draft moves to its window under the same id. A closing window hands unsaved text to the main window to save, since its own request wouldn't survive the close. - Read state, drafts, theme and language follow across windows, and `html[data-desktop]` gates the app-like behaviour in `app/desktop.css`. --- app/(main)/[locale]/layout.tsx | 33 +- app/(main)/[locale]/popout/compose/page.tsx | 7 + .../[locale]/popout/message/[id]/page.tsx | 8 + app/(main)/[locale]/pro/page.tsx | 2 +- app/(main)/layout.tsx | 5 + app/desktop.css | 64 ++ app/globals.css | 19 + app/manifest.ts | 6 +- components/admin/admin-shell.tsx | 4 +- components/boot-skeleton/admin-skeleton.tsx | 2 +- components/boot-skeleton/pro-skeleton.tsx | 2 +- components/boot-skeleton/shell.tsx | 2 +- components/calendar/calendar-app.tsx | 6 +- components/calendar/event-modal.tsx | 2 +- .../command-palette/command-palette.tsx | 2 +- components/contacts/contacts-app.tsx | 4 +- .../__tests__/composer-own-window.test.tsx | 254 ++++++++ .../download-all-attachments.test.tsx | 126 ++++ components/email/email-composer.tsx | 279 +++++++-- components/email/email-context-menu.tsx | 4 + components/email/email-list.tsx | 3 + components/email/email-viewer.tsx | 125 +++- components/email/message-actions.ts | 6 + components/email/reader-attachments.tsx | 17 + components/error/app-error-boundary.tsx | 2 +- components/files/files-app.tsx | 2 +- components/files/preview-shell.tsx | 2 +- components/files/wopi-editor.tsx | 2 +- components/layout/window-title-bar.tsx | 48 ++ components/mail/mail-app.tsx | 36 +- .../notifications/notification-center.tsx | 2 +- components/plugins/plugin-page-app.tsx | 2 +- .../popout/__tests__/popout-host.test.tsx | 233 +++++++ .../__tests__/popout-message-app.test.tsx | 163 +++++ components/popout/main-window-only.tsx | 15 + components/popout/popout-compose-app.tsx | 187 ++++++ components/popout/popout-host.tsx | 187 ++++++ components/popout/popout-message-app.tsx | 194 ++++++ components/popout/popout-shell.tsx | 89 +++ components/popout/window-sync-host.tsx | 10 + components/pro/pro-compose-tab-body.tsx | 21 + components/pro/pro-email-tab-body.tsx | 38 +- components/pro/pro-interface-redirect.tsx | 2 +- .../protocol-launch-handler-provider.tsx | 5 +- components/settings/settings-app.tsx | 6 +- components/ui/dialog.tsx | 4 +- hooks/use-calendar-alerts.ts | 5 +- hooks/use-popouts-available.ts | 53 ++ integration/tests/23-do-pwa-windows.spec.ts | 576 ++++++++++++++++++ lib/__tests__/desktop-window-sync.test.ts | 145 +++++ .../fixtures/fake-broadcast-channel.ts | 26 + lib/__tests__/lite-scripts.test.ts | 3 + lib/__tests__/popout-windows.test.ts | 220 +++++++ lib/__tests__/save-to-folder.test.ts | 124 ++++ lib/__tests__/sw-offline-shell.test.ts | 39 ++ lib/__tests__/token-lock.test.ts | 87 +++ lib/__tests__/window-title-bar.test.tsx | 130 ++++ lib/attachments/save-to-folder.ts | 170 ++++++ lib/auth/token-lock.ts | 51 ++ lib/desktop/boot.ts | 8 + lib/desktop/types.ts | 26 + lib/desktop/window-sync.ts | 42 ++ lib/jmap/client.ts | 19 + lib/offline/shell.ts | 2 +- lib/popout/channel.ts | 226 +++++++ lib/popout/compose-handoff.ts | 122 ++++ lib/popout/compose-payload.ts | 23 + lib/popout/open.ts | 100 +++ lib/popout/support.ts | 27 + lib/popout/window-role.ts | 57 ++ lib/window-controls-overlay.ts | 68 +++ locales/ar/common.json | 17 + locales/ca/common.json | 17 + locales/cs/common.json | 17 + locales/da/common.json | 17 + locales/de/common.json | 17 + locales/en/common.json | 17 + locales/es/common.json | 17 + locales/et/common.json | 17 + locales/fa/common.json | 17 + locales/fr/common.json | 17 + locales/he/common.json | 17 + locales/hu/common.json | 17 + locales/it/common.json | 17 + locales/ja/common.json | 17 + locales/ko/common.json | 17 + locales/lv/common.json | 17 + locales/mn/common.json | 17 + locales/nb/common.json | 17 + locales/nl/common.json | 17 + locales/pl/common.json | 17 + locales/pt/common.json | 17 + locales/ro/common.json | 17 + locales/ru/common.json | 17 + locales/sk/common.json | 17 + locales/th/common.json | 17 + locales/tr/common.json | 17 + locales/uk/common.json | 17 + locales/vi/common.json | 17 + locales/zh-TW/common.json | 17 + locales/zh/common.json | 17 + public/sw.js | 9 +- scripts/lite/lib.mjs | 1 + .../auth-store-shared-session.test.ts | 263 ++++++++ stores/account-store.ts | 4 +- stores/auth-store.ts | 217 ++++++- 106 files changed, 5425 insertions(+), 160 deletions(-) create mode 100644 app/(main)/[locale]/popout/compose/page.tsx create mode 100644 app/(main)/[locale]/popout/message/[id]/page.tsx create mode 100644 app/desktop.css create mode 100644 components/email/__tests__/composer-own-window.test.tsx create mode 100644 components/layout/window-title-bar.tsx create mode 100644 components/popout/__tests__/popout-host.test.tsx create mode 100644 components/popout/__tests__/popout-message-app.test.tsx create mode 100644 components/popout/main-window-only.tsx create mode 100644 components/popout/popout-compose-app.tsx create mode 100644 components/popout/popout-host.tsx create mode 100644 components/popout/popout-message-app.tsx create mode 100644 components/popout/popout-shell.tsx create mode 100644 components/popout/window-sync-host.tsx create mode 100644 hooks/use-popouts-available.ts create mode 100644 integration/tests/23-do-pwa-windows.spec.ts create mode 100644 lib/__tests__/desktop-window-sync.test.ts create mode 100644 lib/__tests__/fixtures/fake-broadcast-channel.ts create mode 100644 lib/__tests__/popout-windows.test.ts create mode 100644 lib/__tests__/save-to-folder.test.ts create mode 100644 lib/__tests__/token-lock.test.ts create mode 100644 lib/__tests__/window-title-bar.test.tsx create mode 100644 lib/attachments/save-to-folder.ts create mode 100644 lib/auth/token-lock.ts create mode 100644 lib/desktop/boot.ts create mode 100644 lib/desktop/types.ts create mode 100644 lib/desktop/window-sync.ts create mode 100644 lib/popout/channel.ts create mode 100644 lib/popout/compose-handoff.ts create mode 100644 lib/popout/compose-payload.ts create mode 100644 lib/popout/open.ts create mode 100644 lib/popout/support.ts create mode 100644 lib/popout/window-role.ts create mode 100644 lib/window-controls-overlay.ts create mode 100644 stores/__tests__/auth-store-shared-session.test.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 9164a715f..305421961 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -33,6 +33,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"; @@ -78,16 +81,22 @@ export default async function LocaleLayout({ - - + + + + + {!IS_LITE && } + + {!IS_LITE && } - {children} - + + + @@ -98,15 +107,19 @@ export default async function LocaleLayout({ - - - + + + + + {/* Offline mode isn't in Lite (no service worker there). */} {!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]/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/desktop.css b/app/desktop.css new file mode 100644 index 000000000..37cff0945 --- /dev/null +++ b/app/desktop.css @@ -0,0 +1,64 @@ +/* + * 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. Message bodies are in their own frame and aren't touched at all. + */ + +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-email-id], + [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; +} 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 1a06f3b58..951269292 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -15,7 +15,8 @@ type WebAppProtocolHandler = { url: string; }; -type ExtendedManifest = MetadataRoute.Manifest & { +type ExtendedManifest = Omit & { + display_override?: Array<"window-controls-overlay" | "standalone" | "minimal-ui" | "browser" | "fullscreen">; protocol_handlers?: WebAppProtocolHandler[]; launch_handler?: { client_mode?: "navigate-existing" | "auto" | "focus-existing" | "navigate-new" @@ -90,6 +91,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 1bccba921..907a6f8bc 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -2153,7 +2153,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = ); return ( -
+
{!isEmbedded && {tShell("skip_to_content")}} {refreshIndicator} @@ -2211,7 +2211,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" ) @@ -2546,7 +2546,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 1b371b44a..71e8a7adc 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -1200,7 +1200,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = }; return ( -
+
{!isEmbedded && {tShell("skip_to_content")}} {refreshIndicator} @@ -1256,7 +1256,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..8555c06fb --- /dev/null +++ b/components/email/__tests__/composer-own-window.test.tsx @@ -0,0 +1,254 @@ +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[]; + +beforeEach(async () => { + creations = []; + 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); + created[key] = { id: `saved-${creations.length}` }; + } + methodResponses.push([method, { 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(): ComposerPopOut & { handed: ComposerDraftData[]; cancelled: number } { + const handed: ComposerDraftData[] = []; + const window = { handed, cancelled: 0, handOver: (data: ComposerDraftData) => { handed.push(data); }, cancel: () => { window.cancelled += 1; } }; + return window; +} + +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('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('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..7b82f8d3c 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,128 @@ 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('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 77163d04b..3bbf8b2f0 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -166,6 +166,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"; /** @@ -280,6 +281,12 @@ export interface ComposerDraftData { unsaved?: boolean; } +/** The window a draft moves to; see EmailComposerProps.popOut. */ +export interface ComposerPopOut { + handOver(data: ComposerDraftData): void; + cancel(): void; +} + interface EmailComposerProps { /** * Submits the message. Resolves `false` when the host sent nothing (it has @@ -343,6 +350,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, and closing the window with unsaved text asks first, so + * the save started on the way out gets to finish. + */ + 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 no window can take it. + */ + rescueDraft?: (draft: RescuedDraft) => boolean; + }; className?: string; initialDraftText?: string; initialData?: ComposerDraftData | null; @@ -489,6 +519,8 @@ export function EmailComposer({ requestCloseRef, onDiscardDraft, onSaveState, + popOut, + ownWindow, className, initialDraftText: initialDraftTextProp, initialData: initialDataProp, @@ -501,6 +533,7 @@ 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'); @@ -1823,34 +1856,60 @@ export function EmailComposer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [effectiveSessionKey]); + const rescueRequestRef = useRef<() => RescuedDraft | null>(() => null); + + // 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: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current, + }); + // Auto-save state on unmount (when user navigates away without explicitly closing) useEffect(() => { return () => { if (explicitCloseRef.current) return; - if (onSaveState && isDirtyRef.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: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current, - }); - } + if (onSaveState && isDirtyRef.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; } + // Sent, discarded or saved and closed: the window closes with nothing to keep. + 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. A plugin may change or stop what is saved, and only runs here. + if (own.rescueDraft && emailHooks.onBeforeDraftAutoSave.size === 0) { + 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); @@ -2652,28 +2711,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 @@ -2707,6 +2755,81 @@ 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, + }, + }; + }; + + // `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 - @@ -2726,19 +2849,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; @@ -2893,6 +3004,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; @@ -3990,8 +4102,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; @@ -4131,6 +4245,64 @@ 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. + await saveDraftRef.current().catch(() => null); + } + const data = snapshotStateRef.current(); + target.handOver({ + ...data, + draftId: draftIdRef.current ?? lastDraftIdRef.current, + draftLocalAccountId: draftLocalAccountIdRef.current, + }); + 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; @@ -4323,6 +4495,7 @@ 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 && ( + + )}
+
{!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 c69d7c2c7..403114962 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -146,6 +146,8 @@ 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 { getQuoteBodies } from "@/lib/email-composer-utils"; import { emailHooks } from "@/lib/plugin-hooks"; import { afterRequestDispatch } from "@/lib/after-request-dispatch"; @@ -2162,6 +2164,28 @@ 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(); + 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[]; @@ -4889,6 +4913,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { handleDiscardDraft(draftId, draft); setPendingDraft(null); }} + popOut={popoutsOn && !isEmbedded ? composerPopOut : undefined} /> </ErrorBoundary> ); @@ -4899,7 +4924,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" @@ -5012,7 +5037,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. @@ -5023,7 +5048,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 @@ -5165,7 +5190,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"> @@ -5527,7 +5552,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 @@ -5712,6 +5737,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/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-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx new file mode 100644 index 000000000..d214fe4d6 --- /dev/null +++ b/components/popout/__tests__/popout-host.test.tsx @@ -0,0 +1,233 @@ +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, watchMainWindow, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; +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 = { + to: ['a@example.test'], subject: 'Closing', body: 'text', cc: [], bcc: [], draftId: 'old', attachments: [], +}; + +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 release: Array<() => void>; + +// 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 and a queue, as a browser has. */ +function stubLocks() { + let held = false; + const waiting: Array<() => void> = []; + release = []; + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { + request(_name: string, options: { signal?: AbortSignal }, callback: () => Promise<unknown>) { + const start = () => { + held = true; + void callback().then(() => { + held = false; + waiting.shift()?.(); + }); + }; + if (held) waiting.push(start); + else start(); + return Promise.resolve(); + }, + }, + }); +} + +beforeEach(() => { + FakeBroadcastChannel.reset(); + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + stubLocks(); + 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(); + useAuthStore.setState(original.auth); + useEmailStore.setState(original.email); + useAccountStore.setState(original.accounts); + vi.unstubAllGlobals(); + Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined }); +}); + +describe('PopoutHost', () => { + it('lends the session to a pop-out that asks', async () => { + render(<PopoutHost />); + popout.post({ type: 'session-request', requestId: 'r1' }); + await settle(); + expect(heard).toContainEqual({ + type: 'session-answer', + requestId: 'r1', + answer: { activeAccountId: 'alice', sessions: [expect.objectContaining({ accountId: 'alice', authHeader: 'Bearer main', authMode: 'oauth' })] }, + }); + }); + + 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 />); + popout.post({ type: 'session-request', requestId: 'r1' }); + await settle(); + expect(heard.filter((message) => message.type === 'session-answer')).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 />); + popout.post({ type: 'session-request', requestId: 'r1', accountId: 'alice', rejectedAuthHeader: 'Bearer main' }); + await settle(); + expect(refresh).toHaveBeenCalledWith({ accountId: 'alice' }); + refresh.mockClear(); + popout.post({ type: 'session-request', requestId: 'r2', accountId: 'alice', rejectedAuthHeader: 'Bearer older' }); + await settle(); + expect(refresh).not.toHaveBeenCalled(); + }); + + it('saves a closing window\'s draft, replacing its version', async () => { + render(<PopoutHost />); + 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 /></>); + popout.post({ type: 'draft-rescue', draft: DRAFT }); + await settle(); + expect(client.createDraft).toHaveBeenCalledTimes(1); + }); + + it('says so when the save fails: the text is gone otherwise', async () => { + client.createDraft.mockRejectedValueOnce(new Error('nope')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + render(<PopoutHost />); + popout.post({ type: 'draft-rescue', draft: DRAFT }); + await settle(); + expect(useToastStore.getState().toasts.map((toast) => toast.type)).toEqual(['error']); + }); + + it('takes over the send\'s outcome once, and offers Undo here', async () => { + render(<><PopoutHost /><PopoutHost /></>); + 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 />); + 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 />); + 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 />); + 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('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')); }); + await settle(); + 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(); + }); +}); 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..876d9eced --- /dev/null +++ b/components/popout/__tests__/popout-message-app.test.tsx @@ -0,0 +1,163 @@ +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>, +})); +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('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/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..9436a3b74 --- /dev/null +++ b/components/popout/popout-compose-app.tsx @@ -0,0 +1,187 @@ +"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 { 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, watchMainWindow } from "@/lib/popout/channel"; +import { receiveComposeHandoff, rememberComposePayload } 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" }; + +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]); + + const ownWindow = useMemo(() => ({ + rescueDraft: mainWindow.rescue, + // 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; + void receiveComposeHandoff(handoffId).then((received) => { + if (cancelled) return; + // Called off or never ready: the composer stayed where it was. + if (received) setPayload(received); + else closePopoutWindow(); + }); + return () => { + cancelled = true; + }; + }, [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..6a45275f5 --- /dev/null +++ b/components/popout/popout-host.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useTranslations } from "next-intl"; +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 { openPopoutChannel, type PopoutChannel, type PopoutSentNotice, type RescuedDraft, type SharedAccountSession } from "@/lib/popout/channel"; +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; + +/** Held by one main window at a time, for what must happen once: a draft saved twice is two drafts. */ +export const POPOUT_LEADER_LOCK = "bulwark-popout-leader"; + +/** The sessions this window can lend to a pop-out. 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; +} + +/** + * 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. Every main window runs one; a pop-out takes + * the first answer. + */ +export function PopoutHost() { + const t = useTranslations(); + const tRef = useRef(t); + tRef.current = t; + + useEffect(() => { + let channel: PopoutChannel | null = null; + let closing = false; + + // The browser gives the lock to the next window when this one goes away. + let leader = false; + const stopLeading = new AbortController(); + void navigator.locks?.request(POPOUT_LEADER_LOCK, { signal: stopLeading.signal }, () => { + leader = true; + return new Promise<void>((resolve) => stopLeading.signal.addEventListener("abort", () => resolve())); + }).catch(() => {}); + + const answerSessionRequest = async (requestId: string, accountId?: string, rejectedAuthHeader?: string) => { + const auth = useAuthStore.getState(); + // 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 && rejectedAuthHeader && auth.getClientForAccount(accountId)?.getAuthHeader() === rejectedAuthHeader) { + await auth.refreshAccessToken({ accountId }); + } + const sessions = shareableSessions().filter((session) => !accountId || session.accountId === accountId); + if (sessions.length === 0) return; + channel?.post({ + type: "session-answer", + requestId, + answer: { activeAccountId: useAuthStore.getState().activeAccountId, sessions }, + }); + }; + + // 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) => { + 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"); + 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(); + } catch (error) { + console.error("Failed to save a closing window's draft:", error); + toast.error(tRef.current("email_composer.save_failed")); + } + }; + + channel = openPopoutChannel((message) => { + switch (message.type) { + case "session-request": + void answerSessionRequest(message.requestId, message.accountId, message.rejectedAuthHeader); + break; + case "sent": + if (!leader) break; + channel?.post({ type: "sent-ack", requestId: message.requestId }); + takeOverSent(message.notice); + break; + case "draft-rescue": + if (leader) void saveRescuedDraft(message.draft); + break; + case "main-ping": + if (leader && !closing) channel?.post({ type: "main-pong" }); + 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 "gone", a pop-out asks who else is there. This window may still + // run long enough to hear that, and must not answer: a draft handed to it + // now would be lost. + const onPageHide = () => { + closing = true; + if (leader) channel?.post({ type: "main-gone" }); + }; + const onPageShow = () => { + closing = false; + }; + window.addEventListener("pagehide", onPageHide); + window.addEventListener("pageshow", onPageShow); + return () => { + window.removeEventListener("pagehide", onPageHide); + window.removeEventListener("pageshow", onPageShow); + stopLeading.abort(); + stopPush(); + stopList(); + stopAccounts(); + if (refreshTimer) clearTimeout(refreshTimer); + if (listTimer) clearTimeout(listTimer); + channel?.close(); + }; + }, []); + + return null; +} diff --git a/components/popout/popout-message-app.tsx b/components/popout/popout-message-app.tsx new file mode 100644 index 000000000..dae6e3c32 --- /dev/null +++ b/components/popout/popout-message-app.tsx @@ -0,0 +1,194 @@ +"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 } 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); + + useEffect(() => { + if (client && mailboxes.length === 0) void useEmailStore.getState().fetchMailboxes(client); + // 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((data: Omit<ProComposeTabData, "sessionId">) => { + const opened = openComposerWindow(login); + if (!opened) { + toast.error(t("popup_blocked")); + return; + } + opened.handOver({ + mode: data.mode, + replyTo: data.replyTo, + initialDraftText: data.initialDraftText, + initialData: data.initialData, + prefill: data.prefill, + sourceEmailId: data.sourceEmailId, + sourceEmailSource: data.sourceEmailSource, + }); + }, [login, t]); + + 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..9ab8f3937 --- /dev/null +++ b/components/popout/popout-shell.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { 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 { toast } from "@/stores/toast-store"; +import { withBasePath } from "@/lib/browser-navigation"; +import { flushComposers } from "@/lib/compose-flush-registry"; +import { openPopoutChannel } from "@/lib/popout/channel"; +import { closePopoutWindow } from "@/lib/popout/window-role"; + +/** How long a closing pop-out waits for its composer to save, when the account went away elsewhere. */ +const CLOSE_FLUSH_MS = 3000; + +/** 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 account was signed out in the main window: save what can be saved, and go. + useEffect(() => { + const channel = openPopoutChannel((message) => { + if (message.type !== "account-removed" || message.accountId !== useAuthStore.getState().activeAccountId) return; + void flushComposers(CLOSE_FLUSH_MS).finally(closePopoutWindow); + }); + return () => channel?.close(); + }, []); + + // Stays on screen: the text in a composer is still worth copying. + useEffect(() => { + const onEnded = () => toast.error(t("session_ended_title"), t("session_ended_body")); + window.addEventListener(SHARED_SESSION_ENDED_EVENT, onEnded); + return () => window.removeEventListener(SHARED_SESSION_ENDED_EVENT, onEnded); + }, [t]); + + 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 === "ready" ? children : status === "loading" ? ( + <PopoutSkeleton label={t("opening")} /> + ) : ( + <PopoutNotice title={t("session_ended_title")} body={t("session_ended_body")}> + <Button onClick={() => window.location.replace(withBasePath("/"))}>{t("open_app")}</Button> + <Button variant="ghost" onClick={closePopoutWindow}>{t("close_window")}</Button> + </PopoutNotice> + )} + </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/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index a49e0f2df..3094fa0ff 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}> @@ -253,6 +273,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 6040ba126..224951911 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -31,6 +31,7 @@ import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; 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'; @@ -65,6 +66,15 @@ 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. + */ + onOpenCompose?: (data: Omit<ProComposeTabData, 'sessionId'>) => void; + /** 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 @@ -120,7 +130,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'); @@ -175,8 +185,12 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, onOpened?: () => void, ) => { const open = (prefill: ComposePrefill | null) => { - composerSessionIdRef.current += 1; - openComposeTabNow({ ...data, sessionId: composerSessionIdRef.current, prefill }); + if (onOpenCompose) { + onOpenCompose({ ...data, prefill }); + } else { + composerSessionIdRef.current += 1; + openComposeTabNow({ ...data, sessionId: composerSessionIdRef.current, prefill }); + } onOpened?.(); }; if (emailHooks.onBeforeCompose.size === 0) { @@ -191,7 +205,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. @@ -242,6 +256,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 @@ -359,6 +380,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 ? { @@ -372,12 +394,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 }, @@ -387,7 +410,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; @@ -448,6 +471,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]); @@ -525,6 +550,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/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx index 885203711..dbf35ed56 100644 --- a/components/protocol/protocol-launch-handler-provider.tsx +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from "react"; import { useTranslations } from "next-intl"; import { usePathname, useRouter } from "@/i18n/navigation"; import { useConfig } from "@/hooks/use-config"; +import { isPopoutPath } from "@/lib/popout/window-role"; import { getPathPrefix } from "@/lib/browser-navigation"; import { productName } from "@/lib/product-name"; import { parseMailto } from "@/lib/protocol-handlers/mailto"; @@ -75,7 +76,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle const pathname = usePathname(); useEffect(() => { - if (pathname.startsWith("/protocol/")) return; + if (pathname.startsWith("/protocol/") || isPopoutPath(pathname)) return; return listenForMailtoRequests((pending) => { savePendingMailto(pending); @@ -90,7 +91,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.targetURL) return; 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/23-do-pwa-windows.spec.ts b/integration/tests/23-do-pwa-windows.spec.ts new file mode 100644 index 000000000..7619949fe --- /dev/null +++ b/integration/tests/23-do-pwa-windows.spec.ts @@ -0,0 +1,576 @@ +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); +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 ?? {})).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 }).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 }).toBe(1); + await expect.poll(async () => (await draftsCalled(subject)).map((draft) => draft.id).includes(first.id), { timeout: 15_000 }).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 }).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 }).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 }).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 }).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 }).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 }).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('the desktop app\'s behaviour', () => { + 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..afe4640cb --- /dev/null +++ b/lib/__tests__/desktop-window-sync.test.ts @@ -0,0 +1,145 @@ +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 = { 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/); + }); + + 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 = {}; + 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 8e103daa4..5bf2edb5d 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, @@ -352,6 +353,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__/popout-windows.test.ts b/lib/__tests__/popout-windows.test.ts new file mode 100644 index 000000000..35ef28022 --- /dev/null +++ b/lib/__tests__/popout-windows.test.ts @@ -0,0 +1,220 @@ +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']) { + expect(isPopoutPath(path), path).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 = { 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 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 = { 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); + + handoff.complete(payload); + await expect(received).resolves.toMatchObject({ mode: 'reply', initialData: { draftId: 'saved-3' } }); + }); + + 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'); + await expect(requestWindowSession({}, 10)).resolves.toBeNull(); + + const main = openPopoutChannel((message) => { + if (message.type === 'session-request') { + main?.post({ type: 'session-answer', requestId: message.requestId, answer: { activeAccountId: 'a', sessions: [] } }); + } + }); + await expect(requestWindowSession({}, 1000)).resolves.toEqual({ activeAccountId: 'a', sessions: [] }); + main?.close(); + }); +}); diff --git a/lib/__tests__/save-to-folder.test.ts b/lib/__tests__/save-to-folder.test.ts new file mode 100644 index 000000000..a7e0f6d7c --- /dev/null +++ b/lib/__tests__/save-to-folder.test.ts @@ -0,0 +1,124 @@ +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[] = []; + + async getFileHandle(name: string, options?: { create?: boolean }) { + 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('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__/sw-offline-shell.test.ts b/lib/__tests__/sw-offline-shell.test.ts index 094fd3e5e..a032751e4 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..feab22fb4 --- /dev/null +++ b/lib/__tests__/token-lock.test.ts @@ -0,0 +1,87 @@ +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 names: string[] = []; + return { + names, + request(name: string, options: { signal?: AbortSignal }, callback: () => Promise<unknown>) { + names.push(name); + 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; + Promise.resolve(callback()).then(resolve, reject); + }); + }); + 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(locks.names).toEqual([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 expect(withTokenLock(1, async () => 'went ahead', 10)).resolves.toBe('went ahead'); + }); + + 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/attachments/save-to-folder.ts b/lib/attachments/save-to-folder.ts new file mode 100644 index 000000000..a5538e8eb --- /dev/null +++ b/lib/attachments/save-to-folder.ts @@ -0,0 +1,170 @@ +/** + * "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; + }; +} + +/** Characters no file system takes in a name, and path parts a sender could smuggle in. */ +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(); + return cleaned && cleaned !== '.' && cleaned !== '..' ? cleaned : fallback; +} + +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; 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 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 { + await writable?.abort?.().catch(() => {}); + // getFileHandle created an empty file; don't leave it looking saved. + if (name) await folder.removeEntry?.(name).catch(() => {}); + result.stopped = 'write'; + return result; + } + } + return result; +} diff --git a/lib/auth/token-lock.ts b/lib/auth/token-lock.ts new file mode 100644 index 000000000..80a9a9fa6 --- /dev/null +++ b/lib/auth/token-lock.ts @@ -0,0 +1,51 @@ +/** + * 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 }, callback: () => 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; + +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: () => Promise<T>, waitMs: number = TOKEN_LOCK_WAIT_MS): Promise<T> { + const locks = lockManager(); + if (!locks) return work(); + + const gaveUp = new AbortController(); + const timer = setTimeout(() => gaveUp.abort(), waitMs); + let started = false; + try { + return await locks.request(tokenLockName(slot), { signal: gaveUp.signal }, () => { + started = true; + clearTimeout(timer); + return work(); + }); + } catch (error) { + // Only the lock itself failing runs the work: an error from the work + // (started) goes to the caller, who decides what it means. + if (started) throw error; + return work(); + } finally { + clearTimeout(timer); + } +} 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/types.ts b/lib/desktop/types.ts new file mode 100644 index 000000000..a134358ca --- /dev/null +++ b/lib/desktop/types.ts @@ -0,0 +1,26 @@ +/** + * The desktop app's preload bridge as the webmail sees it. Feature checks ask + * `window.bulwarkDesktop?.<area>`; in a browser the object isn't there. + * + * Only what the webmail uses so far. The desktop shell fills it in. + */ + +export interface DesktopWindowsBridge { + /** + * Opens a pop-out window itself. A `name` that is already open focuses that + * window and doesn't load `url` again: one window per message. Left out, the + * page uses window.open with a features string, which the shell's + * window-open handler sees too. + */ + open?: (url: string, options: { name: string; width: number; height: number }) => void; +} + +export interface BulwarkDesktopBridge { + windows?: DesktopWindowsBridge; +} + +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 6d35dcdfa..5536ac710 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1683,6 +1683,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}`; } diff --git a/lib/offline/shell.ts b/lib/offline/shell.ts index 88aa07b4e..7059ff739 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']; +const SHELL_PAGES = ['/', '/mail', '/settings', '/popout/compose', '/popout/message/_']; /** * The files of this tab's build. After a deploy the server lists the new diff --git a/lib/popout/channel.ts b/lib/popout/channel.ts new file mode 100644 index 000000000..c92266c40 --- /dev/null +++ b/lib/popout/channel.ts @@ -0,0 +1,226 @@ +/** + * 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. + * + * Session answers carry the auth header. A channel reaches same-origin pages + * only, and those can already ask PUT /api/auth/session for the credentials; + * plugin frames of the untrusted tier have an opaque origin and aren't on it. + */ + +import type { ComposePopoutPayload } from './compose-payload'; + +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?: import('@/lib/message-priority').MessagePriority; previousDraftAccountId?: string; classification?: string | null }; +} + +export type PopoutMessage = + | { type: 'session-request'; requestId: string; accountId?: string; rejectedAuthHeader?: string } + | { type: 'session-answer'; requestId: string; answer: SharedSessionAnswer } + | { type: 'handoff-request'; handoffId: string } + | { type: 'handoff-answer'; handoffId: string; payload: ComposePopoutPayload | null } + | { 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: 'main-ping' } + | { type: 'main-pong' } + | { type: 'main-gone' } + | { 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, from any main window. 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 function requestWindowSession( + options: { accountId?: string; rejectedAuthHeader?: string } = {}, + timeoutMs: number = SESSION_ANSWER_MS, +): Promise<SharedSessionAnswer | null> { + return new Promise((resolve) => { + const requestId = newId(); + let timer: ReturnType<typeof setTimeout> | null = null; + const channel = openPopoutChannel((message) => { + if (message.type !== 'session-answer' || message.requestId !== requestId) return; + finish(message.answer); + }); + const finish = (answer: SharedSessionAnswer | null) => { + if (timer) clearTimeout(timer); + channel?.close(); + resolve(answer); + }; + if (!channel) { + resolve(null); + return; + } + timer = setTimeout(() => finish(null), timeoutMs); + channel.post({ type: 'session-request', requestId, ...options }); + }); +} + +/** 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(); +} + +/** 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 whether a main window is open: a window + * closing has no time to ask. Also hands a closing draft to it. + */ +export function watchMainWindow(): { alive(): boolean; rescue(draft: RescuedDraft): boolean; stop(): void } { + let lastSeen = 0; + const channel = openPopoutChannel((message) => { + if (message.type === 'main-pong') lastSeen = Date.now(); + if (message.type === 'main-gone') { + lastSeen = 0; + // Another main window takes over, and answers this. + channel?.post({ type: 'main-ping' }); + } + }); + channel?.post({ type: 'main-ping' }); + const timer = setInterval(() => channel?.post({ type: 'main-ping' }), MAIN_PING_MS); + const alive = () => Date.now() - lastSeen < MAIN_ALIVE_MS; + return { + alive, + rescue(draft) { + if (!channel || !alive()) 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..2d84bcc2f --- /dev/null +++ b/lib/popout/compose-handoff.ts @@ -0,0 +1,122 @@ +/** + * 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. + */ + +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; + +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; + 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 }); + forget(handoffId); +} + +function forget(handoffId: string): void { + const entry = pending.get(handoffId); + if (entry) clearTimeout(entry.timer); + pending.delete(handoffId); + if (pending.size === 0) { + channel?.close(); + channel = null; + } +} + +export interface ComposeHandoff { + id: string; + complete(payload: ComposePopoutPayload): void; + cancel(): void; +} + +/** Null without BroadcastChannel. */ +export function createComposeHandoff(): ComposeHandoff | null { + channel ??= openPopoutChannel((message) => { + if (message.type !== 'handoff-request') return; + const entry = pending.get(message.handoffId); + if (!entry) return; + entry.requested = true; + deliver(message.handoffId, entry); + }); + if (!channel) return null; + + const id = newPopoutId(); + pending.set(id, { requested: false, timer: setTimeout(() => forget(id), PENDING_TTL_MS) }); + const settle = (payload: ComposePopoutPayload | null) => { + const entry = pending.get(id); + if (!entry) return; + entry.payload = payload; + deliver(id, entry); + }; + return { id, complete: (payload) => settle(payload), cancel: () => settle(null) }; +} + +/** 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. + } +} + +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 listener = openPopoutChannel((message) => { + if (message.type !== 'handoff-answer' || message.handoffId !== handoffId) return; + if (message.payload) rememberComposePayload(handoffId, message.payload); + finish(message.payload); + }); + const finish = (payload: ComposePopoutPayload | null) => { + if (retry) clearInterval(retry); + if (timer) clearTimeout(timer); + listener?.close(); + resolve(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(); + }); +} 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..cbed686aa --- /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. The pop-out is waiting for it. */ + handOver(payload: ComposePopoutPayload): void; + /** 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/support.ts b/lib/popout/support.ts new file mode 100644 index 000000000..75fc54a0f --- /dev/null +++ b/lib/popout/support.ts @@ -0,0 +1,27 @@ +/** + * 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'; + +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 both. + if (typeof BroadcastChannel === 'undefined' || !navigator.locks) 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..87c8f8324 --- /dev/null +++ b/lib/popout/window-role.ts @@ -0,0 +1,57 @@ +/** + * 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'; + +/** First 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 { + const segments = pathname.split('/').filter(Boolean); + const at = segments.indexOf('popout'); + if (at < 0 || segments.slice(0, at).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/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 cc8420d14..fe781a228 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/ca/common.json b/locales/ca/common.json index 6e15f53fc..e1f77dd7f 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/cs/common.json b/locales/cs/common.json index d8426dce2..b657149fc 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/da/common.json b/locales/da/common.json index cf66a7294..2cf84faeb 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/de/common.json b/locales/de/common.json index 3772dbab8..3528ade90 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6451,5 +6451,22 @@ "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." } } diff --git a/locales/en/common.json b/locales/en/common.json index 57a58dab4..d27ee973f 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/es/common.json b/locales/es/common.json index cc786a268..2c1959490 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/et/common.json b/locales/et/common.json index 2743f7f83..b3bd5282f 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/fa/common.json b/locales/fa/common.json index 539f81c1a..e7534be79 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6451,5 +6451,22 @@ "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} منتقل شد." } } diff --git a/locales/fr/common.json b/locales/fr/common.json index fb7d148ec..04d52aad6 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/he/common.json b/locales/he/common.json index 73d75426a..e44b1ebe3 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6452,5 +6452,22 @@ "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}." } } diff --git a/locales/hu/common.json b/locales/hu/common.json index b45fe495e..d957c82c1 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/it/common.json b/locales/it/common.json index a021b7397..ea5b317df 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/ja/common.json b/locales/ja/common.json index f2ea41909..7be2a0971 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6451,5 +6451,22 @@ "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} に移動されました。" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index cbde91d32..e288ab8cd 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6451,5 +6451,22 @@ "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}(으)로 이동되었습니다." } } diff --git a/locales/lv/common.json b/locales/lv/common.json index 02d30c2f9..3caa2488c 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/mn/common.json b/locales/mn/common.json index 4dd86dea2..623ac15ad 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6451,5 +6451,22 @@ "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} руу зөөсөн." } } diff --git a/locales/nb/common.json b/locales/nb/common.json index d98bd1756..9cb532938 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/nl/common.json b/locales/nl/common.json index 4d541f358..80af5c4e4 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/pl/common.json b/locales/pl/common.json index a6a7cd520..60a53be78 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/pt/common.json b/locales/pt/common.json index 685a98aea..079316bd5 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/ro/common.json b/locales/ro/common.json index 45c3fefed..36be951f0 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/ru/common.json b/locales/ru/common.json index b40069b61..b4e299886 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/sk/common.json b/locales/sk/common.json index aa0304452..e2d4f3d6e 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/th/common.json b/locales/th/common.json index 4d2c52754..f19480b77 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6459,5 +6459,22 @@ "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}" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index 4f25997fa..cab9c409e 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6451,5 +6451,22 @@ "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ı." } } diff --git a/locales/uk/common.json b/locales/uk/common.json index 408e920da..11b850991 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6451,5 +6451,22 @@ "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}." } } diff --git a/locales/vi/common.json b/locales/vi/common.json index 6952aec2e..fe4c3bd54 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6463,5 +6463,22 @@ "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}." } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 1236173cf..c1b1dbaa6 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6451,5 +6451,22 @@ "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}。" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index f51d10c28..0b7890579 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6451,5 +6451,22 @@ "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}。" } } diff --git a/public/sw.js b/public/sw.js index 3dc7ff7b8..a5552b96d 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"]); +const APP_SECTIONS = new Set(["", "mail", "settings", "calendar", "contacts", "files", "pro", "apps", "popout"]); /** The page path inside the app ("/mail/inbox"), or null for anything that isn't a page of the app. */ function appPagePath(url) { @@ -138,7 +138,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/lite/lib.mjs b/scripts/lite/lib.mjs index 559c09eca..f2dee815e 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", ]; 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..19dcb2cbb --- /dev/null +++ b/stores/__tests__/auth-store-shared-session.test.ts @@ -0,0 +1,263 @@ +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 { useAccountStore } from '@/stores/account-store'; +import { SHARED_SESSION_ENDED_EVENT, useAuthStore } from '@/stores/auth-store'; + +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; rejectedAuthHeader?: string }>; + +function startMainWindow(answer: (request: { accountId?: string; rejectedAuthHeader?: string }) => { activeAccountId: string; sessions: unknown[] } | null) { + main = openPopoutChannel((message) => { + if (message.type !== 'session-request') return; + requests.push({ accountId: message.accountId, rejectedAuthHeader: message.rejectedAuthHeader }); + const result = answer(message); + if (result) main?.post({ type: 'session-answer', requestId: message.requestId, answer: result as never }); + }); +} + +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('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.rejectedAuthHeader ? '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', rejectedAuthHeader: '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: () => 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: () => 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'); + }); +}); diff --git a/stores/account-store.ts b/stores/account-store.ts index e69b1bbba..f0ae567c9 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import { mainWindowStorage } from '@/lib/popout/window-role'; import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils'; import { authHooks } from '@/lib/plugin-hooks'; @@ -250,6 +251,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 380379382..5c3e6e1c7 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'; @@ -30,6 +30,9 @@ 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 { toAsciiEmail } from '@/lib/idn'; import { flushComposers } from '@/lib/compose-flush-registry'; import { sortIdentities } from '@/lib/identity-order'; @@ -119,6 +122,12 @@ interface AuthState { removeAccount: (accountId: string) => 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; /** @@ -604,28 +613,32 @@ 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> { - // 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 () => { - try { - const tokens = await liteRefreshTokens(slot); - return jsonResponse({ access_token: tokens.accessToken, expires_in: tokens.expiresIn }); - } catch (err) { - if (err instanceof LiteLoginError) return jsonResponse({ error: err.code }, liteErrorStatus(err)); - throw err; // network failure - callers treat it as an outage, not a rejection +function fetchSlotAccessToken(slot: number, opts: { force?: boolean; remember?: boolean } = {}): Promise<Response> { + // One at a time across windows: each must send the refresh token the last + // answer wrote (lib/auth/token-lock.ts). + return withTokenLock(slot, async () => { + // 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 () => { + try { + const tokens = await liteRefreshTokens(slot); + return jsonResponse({ access_token: tokens.accessToken, expires_in: tokens.expiresIn }); + } catch (err) { + if (err instanceof LiteLoginError) return jsonResponse({ error: err.code }, liteErrorStatus(err)); + throw err; // network failure - callers treat it as an outage, not a rejection + } + }); }); } @@ -950,6 +963,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; @@ -1117,6 +1135,53 @@ function snapshotAndClearForSwitch(activeAccountId: string | null): void { const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>(); const refreshPromises = new Map<string, Promise<string | null>>(); +/** This window is a pop-out on another window's session (startSharedSession). */ +let sharedSessionWindow = false; + +/** 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 @@ -2174,6 +2239,13 @@ export const useAuthStore = create<AuthState>()( return refreshPromise; } + if (sharedSessionWindow) { + 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; @@ -3280,6 +3352,106 @@ export const useAuthStore = create<AuthState>()( } }, + startSharedSession: async (options) => { + sharedSessionWindow = true; + set({ isLoading: true, client: null }); + const accountStore = useAccountStore.getState(); + const answer = await requestWindowSession(); + 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); }, @@ -3301,6 +3473,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 {}; From 7ac582f80384b95789b88c3f216383ea0ff4ebaa Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:55:22 +0100 Subject: [PATCH 06/27] fix: compile the desktop trust path out of every other build, and fail a build that keeps it --- app/(main)/[locale]/login/page.tsx | 50 ++++++++++++----------- app/api/auth/totp-token-exchange/route.ts | 3 +- lib/__tests__/standalone-guard.test.ts | 7 ++++ lib/admin/jmap-servers.ts | 8 ++-- lib/desktop/__tests__/trust-flag.test.ts | 8 ++++ lib/desktop/types.ts | 5 ++- scripts/desktop-build-flag.mjs | 3 ++ scripts/standalone-guard.mjs | 9 ++-- 8 files changed, 60 insertions(+), 33 deletions(-) diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index d28ae4039..3ac336bd2 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -993,30 +993,34 @@ function LoginPageContent() { }; /** 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 () => { - const target = jmapEndpoint.trim(); - if (!target) { - setDesktopNotice(tDesktop("server_needed")); - return; + 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); + 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")); } - 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); - 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) => { @@ -1858,7 +1862,7 @@ function LoginPageContent() { </> )} - {IS_DESKTOP_BUILD && !hasServerList && browserSignInAvailable() && ( + {process.env.NEXT_PUBLIC_BULWARK_DESKTOP === "1" && !hasServerList && browserSignInAvailable() && ( <Button type="button" variant="outline" diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index 46e77ac33..a16be7089 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -173,7 +173,8 @@ export async function POST(request: NextRequest) { resolvedServerId = matchedEntry.id; } else if (configuredServerUrl && !customRequested) { upstreamUrl = configuredServerUrl; - } else if (IS_DESKTOP_BUILD && isDesktopTrustedServer(serverUrl)) { + // 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))) { diff --git a/lib/__tests__/standalone-guard.test.ts b/lib/__tests__/standalone-guard.test.ts index f982ecb14..7eac020df 100644 --- a/lib/__tests__/standalone-guard.test.ts +++ b/lib/__tests__/standalone-guard.test.ts @@ -39,6 +39,13 @@ describe('the desktop trust path', () => { 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 the desktop build itself', () => { const dir = tempDir(); put(dir, 'server.js'); diff --git a/lib/admin/jmap-servers.ts b/lib/admin/jmap-servers.ts index fa054875a..5bff4f136 100644 --- a/lib/admin/jmap-servers.ts +++ b/lib/admin/jmap-servers.ts @@ -1,7 +1,6 @@ /** * Multi-server JMAP support: schema, parsing, lookup, and redaction helpers. */ -import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; import { isDesktopTrustedServer } from '@/lib/desktop/trusted-servers'; @@ -226,9 +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. Compiled out of every other - // build, so nothing at runtime can open this path on a server. - if (IS_DESKTOP_BUILD && isDesktopTrustedServer(requestedUrl)) return trimUrl(requestedUrl); + // 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/desktop/__tests__/trust-flag.test.ts b/lib/desktop/__tests__/trust-flag.test.ts index 202a4b1ff..a5534ff90 100644 --- a/lib/desktop/__tests__/trust-flag.test.ts +++ b/lib/desktop/__tests__/trust-flag.test.ts @@ -104,6 +104,14 @@ describe('the flag is fixed at build time', () => { 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/types.ts b/lib/desktop/types.ts index 7ae919aa6..e2fefb496 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -92,9 +92,10 @@ export type DesktopPluginInstallResult = export interface DesktopPluginsBridge { /** * Installs from the directory through the local admin API, with its signature - * and hash checks. The renderer never holds an admin session. Standalone only. + * 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>; + install: (slug: string, version?: string) => Promise<DesktopPluginInstallResult>; } export interface BulwarkDesktopBridge { diff --git a/scripts/desktop-build-flag.mjs b/scripts/desktop-build-flag.mjs index a6f52d122..8f9620507 100644 --- a/scripts/desktop-build-flag.mjs +++ b/scripts/desktop-build-flag.mjs @@ -7,6 +7,9 @@ /** 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. */ +export const DESKTOP_FLAG_READ = 'NEXT_PUBLIC_BULWARK_DESKTOP'; + export function desktopBuildFlag(env = process.env) { return env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' ? '1' : ''; } diff --git a/scripts/standalone-guard.mjs b/scripts/standalone-guard.mjs index d44165b9e..b90081e7a 100644 --- a/scripts/standalone-guard.mjs +++ b/scripts/standalone-guard.mjs @@ -18,7 +18,7 @@ import { existsSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { DESKTOP_TRUST_MARKER, desktopBuildFlag } from './desktop-build-flag.mjs'; +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']); @@ -154,8 +154,11 @@ export function findDesktopTrustPath(standaloneDir) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name !== 'node_modules') visit(full); - } else if (entry.name.endsWith('.js') && readFileSync(full, 'utf8').includes(DESKTOP_TRUST_MARKER)) { - found.push(`${toPosix(path.relative(standaloneDir, full))} (desktop trust path in a server build)`); + } else if (entry.name.endsWith('.js')) { + const text = readFileSync(full, 'utf8'); + if (text.includes(DESKTOP_TRUST_MARKER) || text.includes(DESKTOP_FLAG_READ)) { + found.push(`${toPosix(path.relative(standaloneDir, full))} (desktop trust path in a server build)`); + } } } }; From 33c033584341900dd72767cf60ced229d2997fe2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:09:55 +0100 Subject: [PATCH 07/27] docs: say how a pop-out composer saves its draft on close --- components/email/email-composer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 3bbf8b2f0..abcc22c2c 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -359,8 +359,8 @@ interface EmailComposerProps { popOut?: () => ComposerPopOut | null; /** * This composer is all a window shows (lib/popout). It closes itself once a - * message has gone, and closing the window with unsaved text asks first, so - * the save started on the way out gets to finish. + * 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. */ From 0dcabc9a7cf5cf6b321de4b7907c1674a2a49443 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:13:18 +0100 Subject: [PATCH 08/27] test: type the desktop flag test's environments --- lib/desktop/__tests__/trust-flag.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/desktop/__tests__/trust-flag.test.ts b/lib/desktop/__tests__/trust-flag.test.ts index a5534ff90..c40bd5846 100644 --- a/lib/desktop/__tests__/trust-flag.test.ts +++ b/lib/desktop/__tests__/trust-flag.test.ts @@ -70,14 +70,14 @@ describe('the trusted set, desktop build', () => { 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: NodeJS.ProcessEnv, runtimeEnv: NodeJS.ProcessEnv): Promise<boolean> { + 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)) }, + 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 } }; @@ -96,7 +96,7 @@ describe('the flag is fixed at build time', () => { 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({})).toBe(''); + expect(desktopBuildFlag({} as unknown as NodeJS.ProcessEnv)).toBe(''); }); it('flag.ts reads the variable in the one form the compiler replaces', () => { From 46766ac8aa7dd030b2ab2c7a827a28ca21a4f394 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:57:35 +0100 Subject: [PATCH 09/27] fix: let a server build pass its guard, and let the desktop app save drafts and forget a server on sign-out --- app/(main)/[locale]/login/page.tsx | 7 +- lib/__tests__/standalone-guard.test.ts | 13 ++ lib/compose-flush-registry.ts | 24 +++ lib/desktop/__tests__/shell-contract.test.ts | 163 +++++++++++++++++++ lib/desktop/bridge.ts | 5 + lib/desktop/jmap-route.ts | 13 ++ lib/desktop/types.ts | 16 +- lib/jmap/client.ts | 8 +- scripts/desktop-build-flag.mjs | 8 +- scripts/standalone-guard.mjs | 2 +- stores/account-store.ts | 15 ++ 11 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 lib/desktop/__tests__/shell-contract.test.ts diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 3ac336bd2..bf9b69c44 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -2,7 +2,7 @@ import { Suspense, useState, useEffect, useRef, useCallback, useId } from "react"; import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag"; -import { getDesktopBridge } from "@/lib/desktop/bridge"; +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"; @@ -1017,6 +1017,7 @@ function LoginPageContent() { 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")); @@ -1043,6 +1044,8 @@ function LoginPageContent() { if (signInWithToken) { if (await loginWithToken(effectiveServerUrl, accessToken, persistSession)) { router.push('/'); + } else if (IS_DESKTOP_BUILD) { + forgetDesktopServer(effectiveServerUrl, 'failed'); } return; } @@ -1094,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'); } }; diff --git a/lib/__tests__/standalone-guard.test.ts b/lib/__tests__/standalone-guard.test.ts index 7eac020df..347d8f526 100644 --- a/lib/__tests__/standalone-guard.test.ts +++ b/lib/__tests__/standalone-guard.test.ts @@ -46,6 +46,19 @@ describe('the desktop trust path', () => { 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'); 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__/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/bridge.ts b/lib/desktop/bridge.ts index 852ebbfbe..922a5c1b5 100644 --- a/lib/desktop/bridge.ts +++ b/lib/desktop/bridge.ts @@ -14,6 +14,11 @@ export function getDesktopBridge(): BulwarkDesktopBridge | 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/jmap-route.ts b/lib/desktop/jmap-route.ts index cac1869cb..8eea5a83a 100644 --- a/lib/desktop/jmap-route.ts +++ b/lib/desktop/jmap-route.ts @@ -34,6 +34,19 @@ export function desktopJmapBase(serverUrl: string): string { } } +/** 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); diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index e2fefb496..e049b727b 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -66,11 +66,24 @@ export interface DesktopServersBridge { * LAN or plain http, and lets the app reach it. Standalone mode only. */ prepare: (serverUrl: string) => Promise<DesktopServerPrepareResult>; - /** The origins a session names, for the CORS headers the shell adds for them. */ + /** + * 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 = @@ -106,6 +119,7 @@ export interface BulwarkDesktopBridge { theme?: DesktopThemeBridge; menu?: DesktopMenuBridge; shell?: DesktopShellBridge; + lifecycle?: DesktopLifecycleBridge; servers?: DesktopServersBridge; auth?: DesktopAuthBridge; plugins?: DesktopPluginsBridge; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index f15abdbfb..c0cfe1f27 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,6 +1,6 @@ import { recordJmapCall, reportJmapFailure } from "@/lib/error-reporting/client"; import { getDesktopBridge } from '@/lib/desktop/bridge'; -import { desktopJmapBase, desktopJmapOrigin } from '@/lib/desktop/jmap-route'; +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"; @@ -6504,7 +6504,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; } @@ -6987,6 +6987,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/scripts/desktop-build-flag.mjs b/scripts/desktop-build-flag.mjs index 8f9620507..26f6c62d0 100644 --- a/scripts/desktop-build-flag.mjs +++ b/scripts/desktop-build-flag.mjs @@ -7,8 +7,12 @@ /** 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. */ -export const DESKTOP_FLAG_READ = 'NEXT_PUBLIC_BULWARK_DESKTOP'; +/** + * 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/standalone-guard.mjs b/scripts/standalone-guard.mjs index b90081e7a..192cc94e8 100644 --- a/scripts/standalone-guard.mjs +++ b/scripts/standalone-guard.mjs @@ -156,7 +156,7 @@ export function findDesktopTrustPath(standaloneDir) { if (entry.name !== 'node_modules') visit(full); } else if (entry.name.endsWith('.js')) { const text = readFileSync(full, 'utf8'); - if (text.includes(DESKTOP_TRUST_MARKER) || text.includes(DESKTOP_FLAG_READ)) { + 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)`); } } diff --git a/stores/account-store.ts b/stores/account-store.ts index e69b1bbba..9a368f752 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -1,8 +1,18 @@ import { create } from 'zustand'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { forgetDesktopServer } from '@/lib/desktop/bridge'; import { persist } from 'zustand/middleware'; import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils'; import { authHooks } from '@/lib/plugin-hooks'; +function sameServerOrigin(a: string, b: string): boolean { + try { + return new URL(a).origin === new URL(b).origin; + } catch { + return a === b; + } +} + export interface AccountEntry { /** Unique key: `${username}@${serverHostname}` */ id: string; @@ -149,6 +159,7 @@ export const useAccountStore = create<AccountState>()( removeAccount: (accountId) => { if (!get().accounts.some((a) => a.id === accountId)) return; + const leaving = get().accounts.find((a) => a.id === accountId); set((s) => { const remaining = s.accounts.filter((a) => a.id !== accountId); const wasDefault = s.defaultAccountId === accountId; @@ -173,6 +184,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) => { From 10a2c99b839a11e544d3e4547e8ba92147fae6b0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:22:15 +0100 Subject: [PATCH 10/27] fix: keep syncing when a message has two keywords differing only in case, and compare keywords the way the server does --- lib/store/__tests__/mail-answers.test.ts | 19 +++++++++++++++++++ lib/store/__tests__/outbox.test.ts | 15 ++++++++++++--- lib/store/mail/methods.ts | 11 ++++++----- lib/store/mail/records.ts | 14 +++++++++++++- lib/store/outbox/patches.ts | 10 ++++++---- 5 files changed, 56 insertions(+), 13 deletions(-) diff --git a/lib/store/__tests__/mail-answers.test.ts b/lib/store/__tests__/mail-answers.test.ts index d27aaca1a..90a36a9b8 100644 --- a/lib/store/__tests__/mail-answers.test.ts +++ b/lib/store/__tests__/mail-answers.test.ts @@ -263,6 +263,25 @@ describe('Email/query filters and sorts', () => { }); }); +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()); diff --git a/lib/store/__tests__/outbox.test.ts b/lib/store/__tests__/outbox.test.ts index 610974797..0edf2cc39 100644 --- a/lib/store/__tests__/outbox.test.ts +++ b/lib/store/__tests__/outbox.test.ts @@ -319,9 +319,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); diff --git a/lib/store/mail/methods.ts b/lib/store/mail/methods.ts index 1b28508aa..43c8c87a1 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; @@ -512,7 +513,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/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]); } /** From 706550daf492f8733249ba98502fe50ea67c6ed7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:33:03 +0100 Subject: [PATCH 11/27] fix: keep local mail and the outbox when an automatic sign-out wasn't a real refusal of the login, for 30 days at most --- app/api/auth/token/route.ts | 14 +- lib/__tests__/oauth-token-route.test.ts | 15 ++ lib/offline/credentials-rejected.ts | 9 +- lib/offline/flush.ts | 4 +- lib/offline/offline-mode.ts | 59 +++-- lib/offline/queue.ts | 4 +- lib/offline/state.ts | 45 ++++ lib/sign-out-cleanup.ts | 44 ++-- .../__tests__/auth-store-keep-local.test.ts | 206 ++++++++++++++++++ stores/account-store.ts | 3 + stores/auth-store.ts | 65 ++++-- 11 files changed, 411 insertions(+), 57 deletions(-) create mode 100644 stores/__tests__/auth-store-keep-local.test.ts diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index 59b5834c7..df7aaa169 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -292,7 +292,10 @@ export async function PUT(request: NextRequest) { cookieStore.delete(refreshTokenServerCookieName(slot)); cookieStore.delete(accessTokenCookieName(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 }); } @@ -336,6 +339,15 @@ export async function PUT(request: NextRequest) { } } +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 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/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..53e250173 100644 --- a/lib/offline/flush.ts +++ b/lib/offline/flush.ts @@ -42,7 +42,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 { @@ -152,7 +152,7 @@ export async function flushQueue(account: string, client: JMAPClient, deps: Flus /** 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; + const accounts = account ? [account] : visibleAccounts(); await Promise.all(accounts.map((id) => getStore().area(id, 'outbox.settleStale', { before: now - SENDING_STALE_MS }))); } diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 1993f154d..03b0e140d 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -11,13 +11,15 @@ * - 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'; @@ -52,7 +54,7 @@ import { type PolicyFingerprint, } from './queue'; import { clearHandoffs } from '@/lib/launch-handoff'; -import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state'; +import { clearEvicted, EVICTED_KEEP_MS, isEvicted, isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, readEvicted, visibleAccounts, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state'; import { flushQueue, settleStaleSends } from './flush'; import { LEGACY_DB_NAME, moveLegacyOutbox } from './migrate-legacy'; import { deleteShellCaches, tellServiceWorker } from './shell'; @@ -470,7 +472,7 @@ 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: [] }); return; } @@ -494,9 +496,9 @@ export async function refreshQueueState(): Promise<void> { } export async function refreshUsage(): Promise<void> { - if (!offlineSupported() || useOfflineStore.getState().enabled.length === 0) return; + if (!offlineSupported() || visibleAccounts().length === 0) return; try { - const usage = await Promise.all(useOfflineStore.getState().enabled.map((account) => getStore().area<{ bytes: number }>(account, 'mail.usage'))); + const usage = await Promise.all(visibleAccounts().map((account) => getStore().area<{ bytes: number }>(account, 'mail.usage'))); useOfflineStore.setState({ usageBytes: usage.reduce((sum, { bytes }) => sum + bytes, 0) }); } catch { /* deleted meanwhile */ @@ -699,10 +701,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)); } @@ -743,6 +748,8 @@ export async function wipeEverything(): Promise<void> { /** 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 @@ -758,9 +765,11 @@ export function enforcePolicy(): void { for (const account of useOfflineStore.getState().enabled) { if (!policyDecided(account)) continue; if (!offlineAllowed(account)) { + // 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' }); } } } @@ -882,6 +891,7 @@ export function startOfflineMode(): () => void { cleanups.push(onAccountRemoved((id, reason) => { if (reason === 'explicit') void wipeAccount(id); })); + void sweepAccountsGone(); // Session, then a sync, for every account signed in with offline mode on. const syncSignedIn = () => { @@ -923,23 +933,40 @@ 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(() => {}); + } +} + /** 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: [], usageBytes: 0, syncing: [], lastSyncedAt: {}, startedOffline: false }); + useOfflineStore.setState((s) => ({ enabled: keepLocalStore ? s.enabled : [], outbox: [], pendingChanges: 0, conflicts: [], usageBytes: 0, 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); }); diff --git a/lib/offline/queue.ts b/lib/offline/queue.ts index 5686f1fc5..fccd14044 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'; @@ -40,7 +40,7 @@ interface StoredQueue { /** Everything waiting for one account (or every account with it on), oldest first. */ export async function readQueue(account?: string): Promise<{ patches: number; outbox: OutboxEntry[]; conflicts: ConflictEntry[] }> { - const accounts = account ? [account] : useOfflineStore.getState().enabled; + const accounts = account ? [account] : visibleAccounts(); const read = await Promise.all(accounts.map(async (id) => ({ id, queue: await getStore().area<StoredQueue>(id, 'outbox.read') }))); return { patches: read.reduce((sum, { queue }) => sum + queue.patches, 0), diff --git a/lib/offline/state.ts b/lib/offline/state.ts index 1ba184282..4508c07e5 100644 --- a/lib/offline/state.ts +++ b/lib/offline/state.ts @@ -74,6 +74,51 @@ export function writeEnabled(enabled: string[]): void { } } +/** 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)); +} + export function serverKey(url: string): string { return url.replace(/\/+$/, '').toLowerCase(); } diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 9496e43d5..f7ca42832 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -36,25 +36,38 @@ 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', ]; +const LOCAL_STORE_KEYS = new Set(['bulwark-offline-accounts', 'bulwark-offline-evicted']); + const SIGNED_OUT_SIGNAL = 'bulwark:signed-out'; // 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); } @@ -81,14 +94,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 { @@ -111,18 +125,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/stores/__tests__/auth-store-keep-local.test.ts b/stores/__tests__/auth-store-keep-local.test.ts new file mode 100644 index 000000000..c1db3bae1 --- /dev/null +++ b/stores/__tests__/auth-store-keep-local.test.ts @@ -0,0 +1,206 @@ +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 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/account-store.ts b/stores/account-store.ts index 87207fc8d..072215066 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; 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'; @@ -111,6 +112,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 +165,7 @@ export const useAccountStore = create<AccountState>()( removeAccount: (accountId, reason = 'explicit') => { if (!get().accounts.some((a) => a.id === accountId)) return; + if (reason === 'evicted') markEvicted(accountId); for (const listener of [...removalListeners]) listener(accountId, reason); set((s) => { const remaining = s.accounts.filter((a) => a.id !== accountId); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 9f2cc5608..c891ab06c 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -19,7 +19,7 @@ import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } fro 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'; @@ -31,7 +31,7 @@ import type { Identity } from '@/lib/jmap/types'; import { authHooks } from '@/lib/plugin-hooks'; import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite'; 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'; @@ -154,6 +154,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[] }> = [ @@ -1163,6 +1169,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.'); @@ -1358,7 +1378,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 +1417,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 +1440,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)), ]); } @@ -2216,16 +2238,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 +2263,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 +2334,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 +2359,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 +2453,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 +2469,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 +2872,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 +2898,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); @@ -2927,9 +2955,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'); } }; From b79781cc94462378bae39ba48895e1d8242a640a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:37:10 +0100 Subject: [PATCH 12/27] fix: never lose a draft moving between windows and keep credentials off the window channel Review of the pop-out work found ways to lose text, a wrong sign-out and changes reaching ordinary browser tabs. - Drafts: a closing window hands text over only to a main window holding its login, and asks instead when the last save failed, the browser is offline, a save is running or plugins could still have a say. A failed rescue goes to "Continue draft". The opening window lets go only once the composer is on screen, also for Edit draft in a message window. Send options travel with the draft. Closing during a send asks first. - Sessions: answers are encrypted for the window that asked, and only a window holding the login answers. A renewal queued on the lock can be called off by a sign-out, signs nobody out once its account is gone, and takes the cached token after waiting (Rauthy `nbf`, #552). - Ordinary tabs run none of the host. No lock is held for life. - A message window hides after a refused session and waits for its folders; a composer stays on screen with its text. Another tab dropping a login it couldn't restore no longer closes pop-outs. - Plain-text mail stays selectable in the desktop app. Folder save renames reserved and shell names and skips one refused name. All display-mode checks know the overlay. The attachment bar keeps to one row. The earlier test that accepted text being gone after a failed rescue now asserts that it reaches the "Continue draft" slot. --- app/desktop.css | 16 +- .../__tests__/composer-own-window.test.tsx | 118 ++++++++++++- .../download-all-attachments.test.tsx | 19 +++ components/email/email-composer.tsx | 62 +++++-- components/email/reader-attachments.tsx | 14 +- components/mail/mail-app.tsx | 12 ++ .../__tests__/popout-compose-app.test.tsx | 144 ++++++++++++++++ .../popout/__tests__/popout-host.test.tsx | 150 +++++++++++++---- .../__tests__/popout-message-app.test.tsx | 20 +++ .../popout/__tests__/popout-shell.test.tsx | 156 ++++++++++++++++++ components/popout/popout-compose-app.tsx | 49 +++++- components/popout/popout-host.tsx | 111 ++++++++----- components/popout/popout-message-app.tsx | 27 ++- components/popout/popout-shell.tsx | 66 ++++++-- .../__tests__/pro-email-edit-draft.test.tsx | 30 ++++ components/pro/pro-email-tab-body.tsx | 15 +- .../protocol/mailto-protocol-client.tsx | 6 +- .../protocol-launch-handler-provider.tsx | 7 +- .../protocol/webcal-protocol-client.tsx | 6 +- integration/tests/23-do-pwa-windows.spec.ts | 119 +++++++++++-- lib/__tests__/desktop-window-sync.test.ts | 13 ++ lib/__tests__/popout-windows.test.ts | 137 ++++++++++++++- lib/__tests__/save-to-folder.test.ts | 47 ++++++ lib/__tests__/token-lock.test.ts | 43 ++++- lib/attachments/save-to-folder.ts | 61 ++++++- lib/auth/token-lock.ts | 51 ++++-- lib/popout/channel.ts | 102 ++++++++---- lib/popout/compose-handoff.ts | 94 ++++++++--- lib/popout/open.ts | 4 +- lib/popout/rescued-drafts.ts | 55 ++++++ lib/popout/sealed.ts | 75 +++++++++ lib/popout/support.ts | 5 +- lib/popout/window-role.ts | 17 +- lib/protocol-handlers/session.ts | 5 +- lib/sign-out-cleanup.ts | 1 + locales/ar/common.json | 3 +- locales/ca/common.json | 3 +- locales/cs/common.json | 3 +- locales/da/common.json | 3 +- locales/de/common.json | 3 +- locales/en/common.json | 3 +- locales/es/common.json | 3 +- locales/et/common.json | 3 +- locales/fa/common.json | 3 +- locales/fr/common.json | 3 +- locales/he/common.json | 3 +- locales/hu/common.json | 3 +- locales/it/common.json | 3 +- locales/ja/common.json | 3 +- locales/ko/common.json | 3 +- locales/lv/common.json | 3 +- locales/mn/common.json | 3 +- locales/nb/common.json | 3 +- locales/nl/common.json | 3 +- locales/pl/common.json | 3 +- locales/pt/common.json | 3 +- locales/ro/common.json | 3 +- locales/ru/common.json | 3 +- locales/sk/common.json | 3 +- locales/th/common.json | 3 +- locales/tr/common.json | 3 +- locales/uk/common.json | 3 +- locales/vi/common.json | 3 +- locales/zh-TW/common.json | 3 +- locales/zh/common.json | 3 +- .../auth-store-shared-session.test.ts | 104 +++++++++++- stores/auth-store.ts | 57 ++++--- 67 files changed, 1808 insertions(+), 300 deletions(-) create mode 100644 components/popout/__tests__/popout-compose-app.test.tsx create mode 100644 components/popout/__tests__/popout-shell.test.tsx create mode 100644 lib/popout/rescued-drafts.ts create mode 100644 lib/popout/sealed.ts diff --git a/app/desktop.css b/app/desktop.css index 37cff0945..7f1b55c74 100644 --- a/app/desktop.css +++ b/app/desktop.css @@ -6,7 +6,8 @@ * 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. Message bodies are in their own frame and aren't touched at all. + * 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], @@ -34,7 +35,6 @@ html[data-desktop] :is( [role="tree"], [role="treeitem"], [role="row"], - [data-email-id], [data-composer-header], [data-mv-compose-titlebar], [data-window-title-bar] @@ -62,3 +62,15 @@ html[data-desktop] :is(img, a, svg) { 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/components/email/__tests__/composer-own-window.test.tsx b/components/email/__tests__/composer-own-window.test.tsx index 8555c06fb..03f84e9b5 100644 --- a/components/email/__tests__/composer-own-window.test.tsx +++ b/components/email/__tests__/composer-own-window.test.tsx @@ -31,9 +31,12 @@ 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 = []; + 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: {} } }, @@ -55,9 +58,12 @@ beforeEach(async () => { const created: Record<string, { id: string }> = {}; for (const [key, draft] of Object.entries(args.create)) { creations.push(draft); + if (deferSave) await new Promise<void>((resolve) => { finishSave = resolve; }); created[key] = { id: `saved-${creations.length}` }; } - methodResponses.push([method, { created }, id]); + 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]); @@ -93,12 +99,19 @@ function mount(props: Partial<Props>) { const subjectField = () => screen.getByPlaceholderText(en.email_composer.subject_placeholder); -function target(): ComposerPopOut & { handed: ComposerDraftData[]; cancelled: number } { +function target(shown = true): ComposerPopOut & { handed: ComposerDraftData[]; cancelled: number } { const handed: ComposerDraftData[] = []; - const window = { handed, cancelled: 0, handOver: (data: ComposerDraftData) => { handed.push(data); }, cancel: () => { window.cancelled += 1; } }; + 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({}); @@ -145,6 +158,44 @@ describe('"Open in new window" on a composer', () => { 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 }); @@ -202,6 +253,65 @@ describe('a composer that is all its window shows', () => { 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<void>((resolve) => { finish = resolve; })); + const onClose = vi.fn(); + mount({ + ownWindow: {}, onSend, onClose, + initialData: { ...DRAFT, to: 'them@example.test', subject: 'Going', body: '<p>Hello</p>' }, + }); + 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); diff --git a/components/email/__tests__/download-all-attachments.test.tsx b/components/email/__tests__/download-all-attachments.test.tsx index 7b82f8d3c..65a310b92 100644 --- a/components/email/__tests__/download-all-attachments.test.tsx +++ b/components/email/__tests__/download-all-attachments.test.tsx @@ -357,6 +357,25 @@ describe('Save all attachments to a folder', () => { 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); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 60aedca15..d4ff3a9a8 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -258,6 +258,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; @@ -292,7 +296,8 @@ export interface ComposerDraftData { /** The window a draft moves to; see EmailComposerProps.popOut. */ export interface ComposerPopOut { - handOver(data: ComposerDraftData): void; + /** True once the composer is on screen there. Until then this one keeps the text. */ + handOver(data: ComposerDraftData): Promise<boolean>; cancel(): void; } @@ -378,7 +383,7 @@ interface EmailComposerProps { onDraftSaved?: () => void; /** * Hands a draft to another window to save as this one closes: a request - * started now wouldn't survive. False when no window can take it. + * started now wouldn't survive. False when that can't be done safely. */ rescueDraft?: (draft: RescuedDraft) => boolean; }; @@ -1022,11 +1027,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<MessagePriority>(initialData?.priority ?? 'normal'); const priorityRef = useRef(priority); @@ -1830,6 +1835,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 @@ -1896,6 +1904,7 @@ export function EmailComposer({ }, [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. @@ -1911,6 +1920,9 @@ export function EmailComposer({ // 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) @@ -1936,17 +1948,34 @@ export function EmailComposer({ } return; } - // Sent, discarded or saved and closed: the window closes with nothing to keep. + // 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. A plugin may change or stop what is saved, and only runs here. - if (own.rescueDraft && emailHooks.onBeforeDraftAutoSave.size === 0) { + // 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; + 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. @@ -2855,6 +2884,7 @@ export function EmailComposer({ previousDraftAccountId: replaces ? draftAccountIdRef.current : undefined, classification: classificationRef.current.draftChoice, }, + state: snapshotStateRef.current(), }; }; @@ -4207,6 +4237,7 @@ export function EmailComposer({ const afterCloseRef = useRef<(() => void) | null>(null); const emitClose = () => { + closedRef.current = true; onClose?.(); const afterClose = afterCloseRef.current; afterCloseRef.current = null; @@ -4321,15 +4352,22 @@ export function EmailComposer({ return; } if (isDirtyRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current)) { - // A failed save doesn't stop the move: the text goes along, marked unsaved. + // 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(); - target.handOver({ + 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(); @@ -4494,6 +4532,8 @@ export function EmailComposer({ <div ref={setComposerRootEl} data-testid="email-composer" + // Edits made while the draft moves wouldn't move with it. + inert={isPoppingOut || undefined} className={cn("flex h-full bg-background relative", className)} onKeyDownCapture={handleComposerKeyDown} // Bubble phase, in the same batch as the field's own onChange. A state diff --git a/components/email/reader-attachments.tsx b/components/email/reader-attachments.tsx index 1cddf1627..0e3aa1fbd 100644 --- a/components/email/reader-attachments.tsx +++ b/components/email/reader-attachments.tsx @@ -242,7 +242,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({ const draw = layout === "cards" ? card : tile; return ( - <section aria-label={t("attachments")} className={cn("flex flex-col gap-3", className)} data-testid="reader-attachments" data-layout={layout} {...noMediaSavingProps(downloadsOff)}> + <section aria-label={t("attachments")} className={cn("@container flex flex-col gap-3", className)} data-testid="reader-attachments" data-layout={layout} {...noMediaSavingProps(downloadsOff)}> <div className="flex items-center justify-between gap-3"> <div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground"> <Paperclip className="h-4 w-4 shrink-0" aria-hidden="true" /> @@ -262,10 +262,12 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({ onClick={onDownloadAll} disabled={downloadingAll} title={t("download_all")} - className="shrink-0 gap-1.5 disabled:cursor-wait" + aria-label={t("download_all")} + className="shrink-0 gap-1.5 whitespace-nowrap disabled:cursor-wait" > {downloadingAll ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />} - {t("download_all")} + {/* Just the icon in a narrow pane, so the row stays one row. */} + <span className={onSaveAllToFolder ? "hidden @xl:inline" : undefined}>{t("download_all")}</span> </Button> )} {attachments.length > 1 && !downloadsOff && onSaveAllToFolder && ( @@ -274,11 +276,13 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({ size="sm" onClick={onSaveAllToFolder} disabled={downloadingAll} - className="shrink-0 gap-1.5 disabled:cursor-wait" + title={tWindows("save_all_to_folder")} + aria-label={tWindows("save_all_to_folder")} + className="shrink-0 gap-1.5 whitespace-nowrap disabled:cursor-wait" data-testid="attachments-save-to-folder" > <FolderInput className="h-4 w-4" /> - {tWindows("save_all_to_folder")} + <span className="hidden @xl:inline">{tWindows("save_all_to_folder")}</span> </Button> )} </div> diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index af75e3c96..d475f4f2c 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -152,6 +152,7 @@ import { onOpenEmail, type OpenEmailHandler, type OpenEmailOutcome } from "@/lib 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"; @@ -2265,6 +2266,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { 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; 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 index d214fe4d6..a7629b043 100644 --- a/components/popout/__tests__/popout-host.test.tsx +++ b/components/popout/__tests__/popout-host.test.tsx @@ -1,7 +1,9 @@ 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, watchMainWindow, type PopoutMessage, type RescuedDraft } from '@/lib/popout/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'; @@ -11,14 +13,16 @@ 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 release: Array<() => void>; +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. @@ -30,25 +34,18 @@ const settle = async (ms = 10) => { } }; -/** Web Locks with one holder and a queue, as a browser has. */ +/** Web Locks with one holder per name, as a browser has. */ function stubLocks() { - let held = false; - const waiting: Array<() => void> = []; - release = []; + const held = new Set<string>(); + lockRequests = []; Object.defineProperty(navigator, 'locks', { configurable: true, value: { - request(_name: string, options: { signal?: AbortSignal }, callback: () => Promise<unknown>) { - const start = () => { - held = true; - void callback().then(() => { - held = false; - waiting.shift()?.(); - }); - }; - if (held) waiting.push(start); - else start(); - return Promise.resolve(); + 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({})); }, }, }); @@ -57,7 +54,10 @@ function stubLocks() { beforeEach(() => { FakeBroadcastChannel.reset(); vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + // The bridge makes pop-outs available without stubbing the display mode. + window.bulwarkDesktop = { windows: {} }; stubLocks(); + sessionStorage.clear(); heard = []; popout = openPopoutChannel((message) => heard.push(message))!; client = { createDraft: vi.fn(async () => 'new'), getAuthHeader: () => 'Bearer main', isOfflineOnly: () => false }; @@ -80,6 +80,7 @@ beforeEach(() => { afterEach(() => { cleanup(); popout.close(); + delete window.bulwarkDesktop; useAuthStore.setState(original.auth); useEmailStore.setState(original.email); useAccountStore.setState(original.accounts); @@ -87,41 +88,68 @@ afterEach(() => { Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined }); }); -describe('PopoutHost', () => { - it('lends the session to a pop-out that asks', async () => { +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 />); - popout.post({ type: 'session-request', requestId: 'r1' }); await settle(); - expect(heard).toContainEqual({ - type: 'session-answer', - requestId: 'r1', - answer: { activeAccountId: 'alice', sessions: [expect.objectContaining({ accountId: 'alice', authHeader: 'Bearer main', authMode: 'oauth' })] }, + 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 />); - popout.post({ type: 'session-request', requestId: 'r1' }); await settle(); - expect(heard.filter((message) => message.type === 'session-answer')).toEqual([]); + 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 />); - popout.post({ type: 'session-request', requestId: 'r1', accountId: 'alice', rejectedAuthHeader: 'Bearer main' }); await settle(); + await requestWindowSession({ accountId: 'alice', rejectedAuthHeader: 'Bearer main' }, 2000); expect(refresh).toHaveBeenCalledWith({ accountId: 'alice' }); refresh.mockClear(); - popout.post({ type: 'session-request', requestId: 'r2', accountId: 'alice', rejectedAuthHeader: 'Bearer older' }); - await settle(); + 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); @@ -131,22 +159,45 @@ describe('PopoutHost', () => { it('saves it once with two main windows open', async () => { render(<><PopoutHost /><PopoutHost /></>); - popout.post({ type: 'draft-rescue', draft: DRAFT }); + await settle(); + popout.post({ type: 'draft-rescue', draft: { ...DRAFT, rescueKey: 'k' } }); await settle(); expect(client.createDraft).toHaveBeenCalledTimes(1); }); - it('says so when the save fails: the text is gone otherwise', async () => { - client.createDraft.mockRejectedValueOnce(new Error('nope')); + 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(); @@ -162,6 +213,7 @@ describe('PopoutHost', () => { 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); @@ -171,6 +223,7 @@ describe('PopoutHost', () => { 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' }); @@ -178,6 +231,7 @@ describe('PopoutHost', () => { 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' }); @@ -197,6 +251,19 @@ describe('watchMainWindow', () => { 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(); @@ -218,7 +285,10 @@ describe('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(); @@ -231,3 +301,19 @@ describe('watchMainWindow', () => { 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 index 876d9eced..8d877a955 100644 --- a/components/popout/__tests__/popout-message-app.test.tsx +++ b/components/popout/__tests__/popout-message-app.test.tsx @@ -30,6 +30,7 @@ vi.mock('@/components/pro/pro-email-tab-body', () => ({ 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() })); @@ -87,6 +88,25 @@ describe('PopoutMessageApp', () => { 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(); 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/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 9436a3b74..6f0bebeff 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -8,13 +8,14 @@ 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, watchMainWindow } from "@/lib/popout/channel"; -import { receiveComposeHandoff, rememberComposePayload } from "@/lib/popout/compose-handoff"; +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"; @@ -27,6 +28,9 @@ 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); @@ -128,8 +132,23 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; 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: mainWindow.rescue, + 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 }); @@ -168,14 +187,30 @@ export function PopoutComposeApp() { useEffect(() => { if (!handoffId) return; let cancelled = false; - void receiveComposeHandoff(handoffId).then((received) => { + 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; - // Called off or never ready: the composer stayed where it was. - if (received) setPayload(received); - else closePopoutWindow(); + 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]); diff --git a/components/popout/popout-host.tsx b/components/popout/popout-host.tsx index 6a45275f5..54bf13630 100644 --- a/components/popout/popout-host.tsx +++ b/components/popout/popout-host.tsx @@ -2,22 +2,26 @@ 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 { openPopoutChannel, type PopoutChannel, type PopoutSentNotice, type RescuedDraft, type SharedAccountSession } from "@/lib/popout/channel"; +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; -/** Held by one main window at a time, for what must happen once: a draft saved twice is two drafts. */ -export const POPOUT_LEADER_LOCK = "bulwark-popout-leader"; - -/** The sessions this window can lend to a pop-out. Empty until it is signed in, and in the demo. */ +/** 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 []; @@ -37,43 +41,54 @@ function shareableSessions(): SharedAccountSession[] { 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. Every main window runs one; a pop-out takes - * the first answer. + * 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>>(); - // The browser gives the lock to the next window when this one goes away. - let leader = false; - const stopLeading = new AbortController(); - void navigator.locks?.request(POPOUT_LEADER_LOCK, { signal: stopLeading.signal }, () => { - leader = true; - return new Promise<void>((resolve) => stopLeading.signal.addEventListener("abort", () => resolve())); - }).catch(() => {}); - - const answerSessionRequest = async (requestId: string, accountId?: string, rejectedAuthHeader?: string) => { + 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 && rejectedAuthHeader && auth.getClientForAccount(accountId)?.getAuthHeader() === rejectedAuthHeader) { - await auth.refreshAccessToken({ accountId }); + if (accountId && rejectedFingerprint) { + const current = auth.getClientForAccount(accountId)?.getAuthHeader(); + if (current && await credentialFingerprint(current) === rejectedFingerprint) await auth.refreshAccessToken({ accountId }); } - const sessions = shareableSessions().filter((session) => !accountId || session.accountId === accountId); + const sessions = shareableSessions(); if (sessions.length === 0) return; - channel?.post({ - type: "session-answer", - requestId, - answer: { activeAccountId: useAuthStore.getState().activeAccountId, sessions }, - }); + 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. @@ -102,37 +117,59 @@ export function PopoutHost() { refreshList(); }; - const saveRescuedDraft = async (draft: RescuedDraft) => { + 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"); - await client.createDraft( + 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.requestId, message.accountId, message.rejectedAuthHeader); + void answerSessionRequest(message); break; case "sent": - if (!leader) break; - channel?.post({ type: "sent-ack", requestId: message.requestId }); - takeOverSent(message.notice); + claimOnce(`bulwark-popout-sent:${message.requestId}`, () => { + channel?.post({ type: "sent-ack", requestId: message.requestId }); + takeOverSent(message.notice); + }); break; case "draft-rescue": - if (leader) void saveRescuedDraft(message.draft); + 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 (leader && !closing) channel?.post({ type: "main-pong" }); + if (!closing) channel?.post({ type: "main-pong", windowId, accountIds: shareableSessions().map((session) => session.accountId) }); break; case "mail-changed": refreshList(); @@ -158,12 +195,11 @@ export function PopoutHost() { if (!ids.has(account.id)) channel?.post({ type: "account-removed", accountId: account.id }); } }); - // Hearing "gone", a pop-out asks who else is there. This window may still - // run long enough to hear that, and must not answer: a draft handed to it - // now would be lost. + // Hearing a ping late in closing, this window must not answer: a draft + // handed to it now would be lost. const onPageHide = () => { closing = true; - if (leader) channel?.post({ type: "main-gone" }); + channel?.post({ type: "main-gone", windowId }); }; const onPageShow = () => { closing = false; @@ -173,7 +209,6 @@ export function PopoutHost() { return () => { window.removeEventListener("pagehide", onPageHide); window.removeEventListener("pageshow", onPageShow); - stopLeading.abort(); stopPush(); stopList(); stopAccounts(); @@ -181,7 +216,7 @@ export function PopoutHost() { 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 index dae6e3c32..c7841a2b1 100644 --- a/components/popout/popout-message-app.tsx +++ b/components/popout/popout-message-app.tsx @@ -15,7 +15,7 @@ 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 } from "./popout-shell"; +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; @@ -106,8 +106,18 @@ function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string 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 && mailboxes.length === 0) void useEmailStore.getState().fetchMailboxes(client); + 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]); @@ -133,13 +143,13 @@ function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string }, []); // Reply and forward open a composer window of their own. - const openCompose = useCallback((data: Omit<ProComposeTabData, "sessionId">) => { + const openCompose = useCallback(async (data: Omit<ProComposeTabData, "sessionId">) => { const opened = openComposerWindow(login); if (!opened) { toast.error(t("popup_blocked")); - return; + return false; } - opened.handOver({ + const shown = await opened.handOver({ mode: data.mode, replyTo: data.replyTo, initialDraftText: data.initialDraftText, @@ -148,8 +158,15 @@ function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string 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")}> diff --git a/components/popout/popout-shell.tsx b/components/popout/popout-shell.tsx index 9ab8f3937..c07ec4fe7 100644 --- a/components/popout/popout-shell.tsx +++ b/components/popout/popout-shell.tsx @@ -1,18 +1,20 @@ "use client"; -import { useEffect, useState } from "react"; +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 { toast } from "@/stores/toast-store"; import { withBasePath } from "@/lib/browser-navigation"; import { flushComposers } from "@/lib/compose-flush-registry"; -import { openPopoutChannel } from "@/lib/popout/channel"; +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 }: { @@ -36,32 +38,60 @@ export function PopoutShell({ kind, accountId, children }: { }; }, [accountId]); - // The account was signed out in the main window: save what can be saved, and go. + // 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) => { - if (message.type !== "account-removed" || message.accountId !== useAuthStore.getState().activeAccountId) return; - void flushComposers(CLOSE_FLUSH_MS).finally(closePopoutWindow); + 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(); + }); }); - return () => channel?.close(); - }, []); + const stopSignOut = onSignedOutElsewhere(() => void endSession()); + return () => { + channel?.close(); + stopSignOut(); + }; + }, [endSession]); - // Stays on screen: the text in a composer is still worth copying. + // 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 = () => toast.error(t("session_ended_title"), t("session_ended_body")); + const onEnded = () => setStatus("no-session"); window.addEventListener(SHARED_SESSION_ENDED_EVENT, onEnded); return () => window.removeEventListener(SHARED_SESSION_ENDED_EVENT, onEnded); - }, [t]); + }, []); + + 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 === "ready" ? children : status === "loading" ? ( - <PopoutSkeleton label={t("opening")} /> - ) : ( - <PopoutNotice title={t("session_ended_title")} body={t("session_ended_body")}> - <Button onClick={() => window.location.replace(withBasePath("/"))}>{t("open_app")}</Button> - <Button variant="ghost" onClick={closePopoutWindow}>{t("close_window")}</Button> - </PopoutNotice> + {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> ); } 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-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index e5056670a..97a8c6b67 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -69,9 +69,10 @@ interface ProEmailViewProps { 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. + * 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'>) => void; + 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. */ @@ -187,11 +188,13 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, ) => { const open = (prefill: ComposePrefill | null) => { if (onOpenCompose) { - onOpenCompose({ ...data, prefill }); - } else { - composerSessionIdRef.current += 1; - openComposeTabNow({ ...data, sessionId: composerSessionIdRef.current, prefill }); + void onOpenCompose({ ...data, prefill }).then((shown) => { + if (shown) onOpened?.(); + }); + return; } + composerSessionIdRef.current += 1; + openComposeTabNow({ ...data, sessionId: composerSessionIdRef.current, prefill }); onOpened?.(); }; if (emailHooks.onBeforeCompose.size === 0) { 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 3cab2f174..d3d8f4942 100644 --- a/components/protocol/protocol-launch-handler-provider.tsx +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -9,6 +9,7 @@ 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 { @@ -46,7 +47,6 @@ type LaunchParams = { targetURL?: string; files?: ReadonlyArray<{ kind?: string; getFile?: () => Promise<File> }>; }; -type StandaloneNavigator = Navigator & { standalone?: boolean }; declare global { interface Window { @@ -92,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"); 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/integration/tests/23-do-pwa-windows.spec.ts b/integration/tests/23-do-pwa-windows.spec.ts index 7619949fe..a573d1707 100644 --- a/integration/tests/23-do-pwa-windows.spec.ts +++ b/integration/tests/23-do-pwa-windows.spec.ts @@ -24,6 +24,8 @@ import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; 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') @@ -412,7 +414,7 @@ test.describe('pop-out windows', () => { 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 ?? {})).toEqual([trash!.id]); + 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 }) => { @@ -482,14 +484,14 @@ test.describe('a composer in its own window', () => { 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 }).toBe(1); + 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 }).toBe(1); - await expect.poll(async () => (await draftsCalled(subject)).map((draft) => draft.id).includes(first.id), { timeout: 15_000 }).toBe(false); + 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(); @@ -497,7 +499,7 @@ test.describe('a composer in its own window', () => { 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 }).toBe(0); + 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(); @@ -507,7 +509,7 @@ test.describe('a composer in its own window', () => { 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 }).toBe(1); + 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. @@ -517,9 +519,9 @@ test.describe('a composer in its own window', () => { await popup.close({ runBeforeUnload: true }); await closed; expect(prompted).toBe(false); - await expect.poll(async () => (await draftsCalled(typed)).length, { timeout: 20_000 }).toBe(1); + 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 }).toBe(0); + await expect.poll(async () => (await draftsCalled(subject)).filter((draft) => draft.subject === subject).length, { timeout: 20_000, intervals: POLL }).toBe(0); await page.context().close(); }); @@ -527,7 +529,7 @@ test.describe('a composer in its own window', () => { 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 }).toBe(1); + 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); @@ -541,7 +543,7 @@ test.describe('a composer in its own window', () => { await popup.close({ runBeforeUnload: true }); await asked; expect(popup.isClosed()).toBe(false); - await expect.poll(async () => (await draftsCalled(typed)).length, { timeout: 20_000 }).toBe(1); + 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 }) => { @@ -558,7 +560,104 @@ test.describe('a composer in its own window', () => { }); }); +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(); diff --git a/lib/__tests__/desktop-window-sync.test.ts b/lib/__tests__/desktop-window-sync.test.ts index afe4640cb..260c8f788 100644 --- a/lib/__tests__/desktop-window-sync.test.ts +++ b/lib/__tests__/desktop-window-sync.test.ts @@ -117,6 +117,19 @@ describe('the app-like behaviour', () => { 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())(); } diff --git a/lib/__tests__/popout-windows.test.ts b/lib/__tests__/popout-windows.test.ts index 35ef28022..0832f163e 100644 --- a/lib/__tests__/popout-windows.test.ts +++ b/lib/__tests__/popout-windows.test.ts @@ -1,3 +1,5 @@ +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'; @@ -37,12 +39,29 @@ describe('isPopoutPath', () => { 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']) { + 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'); @@ -95,6 +114,13 @@ describe('popoutsAvailable', () => { 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 })); @@ -177,10 +203,47 @@ describe('moving a draft to a pop-out', () => { await settle(); expect(done).toBe(false); - handoff.complete(payload); + 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()!; @@ -207,14 +270,82 @@ describe('moving a draft to a pop-out', () => { 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') { - main?.post({ type: 'session-answer', requestId: message.requestId, answer: { activeAccountId: 'a', sessions: [] } }); + 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 index a7e0f6d7c..f2090a813 100644 --- a/lib/__tests__/save-to-folder.test.ts +++ b/lib/__tests__/save-to-folder.test.ts @@ -6,8 +6,18 @@ class FakeFolder implements FolderHandle { 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, ''); @@ -104,6 +114,43 @@ describe('saveFilesToFolder', () => { }); }); +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); diff --git a/lib/__tests__/token-lock.test.ts b/lib/__tests__/token-lock.test.ts index feab22fb4..b3ff36106 100644 --- a/lib/__tests__/token-lock.test.ts +++ b/lib/__tests__/token-lock.test.ts @@ -4,11 +4,13 @@ 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 }, callback: () => Promise<unknown>) { + 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; @@ -18,7 +20,8 @@ function fakeLocks() { }); void previous.catch(() => {}).then(() => { if (aborted) return; - Promise.resolve(callback()).then(resolve, reject); + held.add(name); + Promise.resolve(callback({})).then(resolve, reject).finally(() => held.delete(name)); }); }); queues.set(name, run.catch(() => {})); @@ -64,7 +67,7 @@ describe('withTokenLock', () => { stubLocks(locks); await withTokenLock(0, async () => 'x'); await withTokenLock(2, async () => 'y'); - expect(locks.names).toEqual([tokenLockName(0), tokenLockName(2)]); + expect(new Set(locks.names)).toEqual(new Set([tokenLockName(0), tokenLockName(2)])); expect(tokenLockName(0)).not.toBe(tokenLockName(2)); }); @@ -75,7 +78,39 @@ describe('withTokenLock', () => { it('goes ahead when another window holds the lock too long, rather than never renewing', async () => { stubLocks(fakeLocks()); void withTokenLock(1, () => new Promise(() => {})); - await expect(withTokenLock(1, async () => 'went ahead', 10)).resolves.toBe('went ahead'); + 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 () => { diff --git a/lib/attachments/save-to-folder.ts b/lib/attachments/save-to-folder.ts index a5538e8eb..2c075b06c 100644 --- a/lib/attachments/save-to-folder.ts +++ b/lib/attachments/save-to-folder.ts @@ -68,12 +68,53 @@ export function createUniqueNamer(fallback = 'attachment'): (raw: string) => str }; } -/** Characters no file system takes in a name, and path parts a sender could smuggle in. */ +/** 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(); - return cleaned && cleaned !== '.' && cleaned !== '..' ? cleaned : fallback; + 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> { @@ -106,17 +147,17 @@ export interface FolderSaveResult { folder: string; total: number; saved: number; - /** Files that couldn't be read from the server; the rest still went. */ + /** 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 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. + * 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, @@ -158,10 +199,14 @@ export async function saveFilesToFolder( await writable.write(data); await writable.close(); result.saved += 1; - } catch { + } 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; } diff --git a/lib/auth/token-lock.ts b/lib/auth/token-lock.ts index 80a9a9fa6..6a4b6906e 100644 --- a/lib/auth/token-lock.ts +++ b/lib/auth/token-lock.ts @@ -12,12 +12,23 @@ */ interface LockManagerLike { - request<T>(name: string, options: { signal?: AbortSignal }, callback: () => Promise<T>): Promise<T>; + 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; @@ -27,25 +38,41 @@ export function tokenLockName(slot: number): string { return `bulwark-token-refresh:${slot}`; } -export async function withTokenLock<T>(slot: number, work: () => Promise<T>, waitMs: number = TOKEN_LOCK_WAIT_MS): Promise<T> { +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(); + 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 timer = setTimeout(() => gaveUp.abort(), waitMs); - let started = false; + const forwardAbort = () => gaveUp.abort(); + options.signal?.addEventListener('abort', forwardAbort); + let timer: ReturnType<typeof setTimeout> | undefined; try { - return await locks.request(tokenLockName(slot), { signal: gaveUp.signal }, () => { - started = true; + 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 work(); + return run(true); }); } catch (error) { - // Only the lock itself failing runs the work: an error from the work - // (started) goes to the caller, who decides what it means. - if (started) throw error; - return work(); + // 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/popout/channel.ts b/lib/popout/channel.ts index c92266c40..eb3c78134 100644 --- a/lib/popout/channel.ts +++ b/lib/popout/channel.ts @@ -3,12 +3,18 @@ * 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. * - * Session answers carry the auth header. A channel reaches same-origin pages - * only, and those can already ask PUT /api/auth/session for the credentials; - * plugin frames of the untrusted tier have an opaque origin and aren't on 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'; @@ -69,22 +75,29 @@ export interface RescuedDraft { htmlBody?: string; inReplyTo?: string[]; references?: string[]; - options?: { priority?: import('@/lib/message-priority').MessagePriority; previousDraftAccountId?: string; classification?: string | null }; + 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; accountId?: string; rejectedAuthHeader?: string } - | { type: 'session-answer'; requestId: string; answer: SharedSessionAnswer } + | { 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' } - | { type: 'main-gone' } + | { type: 'main-pong'; windowId: string; accountIds: string[] } + | { type: 'main-gone'; windowId: string } | { type: 'account-removed'; accountId: string }; export interface PopoutChannel { @@ -131,33 +144,43 @@ export { newId as newPopoutId }; export const SESSION_ANSWER_MS = 3000; /** - * The session of a window that is signed in, from any main window. With - * `rejectedAuthHeader` the server just refused that credential: the main + * 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 function requestWindowSession( +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 channel = openPopoutChannel((message) => { - if (message.type !== 'session-answer' || message.requestId !== requestId) return; - finish(message.answer); - }); 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, ...options }); + channel.post({ + type: 'session-request', + requestId, + publicKey: opener.publicKey, + ...(options.accountId ? { accountId: options.accountId } : {}), + ...(rejectedFingerprint ? { rejectedFingerprint } : {}), + }); }); } @@ -190,31 +213,54 @@ export function announceMailChanged(accountId: string | null): void { 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 whether a main window is open: a window - * closing has no time to ask. Also hands a closing draft to it. + * 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(): boolean; rescue(draft: RescuedDraft): boolean; stop(): void } { - let lastSeen = 0; +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') lastSeen = Date.now(); - if (message.type === 'main-gone') { - lastSeen = 0; - // Another main window takes over, and answers this. - channel?.post({ type: 'main-ping' }); - } + 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 = () => Date.now() - lastSeen < MAIN_ALIVE_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()) return false; + if (!channel || !alive(draft.localAccountId)) return false; channel.post({ type: 'draft-rescue', draft }); return true; }, diff --git a/lib/popout/compose-handoff.ts b/lib/popout/compose-handoff.ts index 2d84bcc2f..aad0dee63 100644 --- a/lib/popout/compose-handoff.ts +++ b/lib/popout/compose-handoff.ts @@ -1,7 +1,9 @@ /** * 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. + * 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'; @@ -13,6 +15,8 @@ const PENDING_TTL_MS = 120_000; 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:'; @@ -20,6 +24,8 @@ 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>; } @@ -29,12 +35,17 @@ 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 }); - forget(handoffId); + // 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) { + clearTimeout(entry.timer); + if (entry.renderTimer) clearTimeout(entry.renderTimer); + entry.rendered?.(false); + } pending.delete(handoffId); if (pending.size === 0) { channel?.close(); @@ -44,30 +55,55 @@ function forget(handoffId: string): void { export interface ComposeHandoff { id: string; - complete(payload: ComposePopoutPayload): void; + /** 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') return; - const entry = pending.get(message.handoffId); - if (!entry) return; - entry.requested = true; - deliver(message.handoffId, entry); + 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) }); - const settle = (payload: ComposePopoutPayload | null) => { - const entry = pending.get(id); - if (!entry) return; - entry.payload = payload; - deliver(id, entry); + 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); + }, }; - return { id, complete: (payload) => settle(payload), cancel: () => settle(null) }; } /** Keeps the draft with this window, so a reload starts from it. */ @@ -79,7 +115,8 @@ export function rememberComposePayload(handoffId: string, payload: ComposePopout } } -function rememberedComposePayload(handoffId: string): ComposePopoutPayload | null { +/** 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; @@ -99,17 +136,17 @@ export function receiveComposeHandoff(handoffId: string, timeoutMs: number = HAN return new Promise((resolve) => { let retry: ReturnType<typeof setInterval> | null = null; let timer: ReturnType<typeof setTimeout> | null = null; - const listener = openPopoutChannel((message) => { - if (message.type !== 'handoff-answer' || message.handoffId !== handoffId) return; - if (message.payload) rememberComposePayload(handoffId, message.payload); - finish(message.payload); - }); 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; @@ -120,3 +157,18 @@ export function receiveComposeHandoff(handoffId: string, timeoutMs: number = HAN 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/open.ts b/lib/popout/open.ts index cbed686aa..e1d51ea37 100644 --- a/lib/popout/open.ts +++ b/lib/popout/open.ts @@ -76,8 +76,8 @@ export function openMessageWindow(target: MessageWindowTarget): boolean { } export interface ComposerWindow { - /** Hands the draft over. The pop-out is waiting for it. */ - handOver(payload: ComposePopoutPayload): void; + /** 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; } 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 index 75fc54a0f..f06f22aeb 100644 --- a/lib/popout/support.ts +++ b/lib/popout/support.ts @@ -7,6 +7,7 @@ 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; @@ -16,8 +17,8 @@ export function hasDesktopWindows(): boolean { 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 both. - if (typeof BroadcastChannel === 'undefined' || !navigator.locks) 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 diff --git a/lib/popout/window-role.ts b/lib/popout/window-role.ts index 87c8f8324..de20ee6cf 100644 --- a/lib/popout/window-role.ts +++ b/lib/popout/window-role.ts @@ -8,14 +8,25 @@ */ import { withBasePath } from '@/lib/browser-navigation'; +import { locales } from '@/i18n/routing'; -/** First path segments that are pages of their own, so a files path like /files/popout/compose isn't taken for a pop-out. */ +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 { - const segments = pathname.split('/').filter(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 || segments.slice(0, at).some((segment) => OTHER_SECTIONS.has(segment))) return false; + 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'); } 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 fbdd44507..af06ead2b 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -38,6 +38,7 @@ const SIGNED_OUT_STORAGE_KEYS = [ ]; 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>(); diff --git a/locales/ar/common.json b/locales/ar/common.json index 172d28651..afe8261d0 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6498,6 +6498,7 @@ "message_gone_title": "لم تعد هذه الرسالة موجودة", "message_gone_body": "تم حذفها أو نقلها إلى حساب آخر.", "message_moved": "تم نقل هذه الرسالة إلى مجلد آخر.", - "message_moved_to": "تم نقل هذه الرسالة إلى {folder}." + "message_moved_to": "تم نقل هذه الرسالة إلى {folder}.", + "window_not_ready": "لم تفتح النافذة الجديدة في الوقت المناسب. تبقى مسودتك هنا." } } diff --git a/locales/ca/common.json b/locales/ca/common.json index 57ab12d13..9feba035d 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6498,6 +6498,7 @@ "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}." + "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í." } } diff --git a/locales/cs/common.json b/locales/cs/common.json index 811b3987f..1410cb212 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6498,6 +6498,7 @@ "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}." + "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." } } diff --git a/locales/da/common.json b/locales/da/common.json index 53e827736..1bc82eb46 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6498,6 +6498,7 @@ "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}." + "message_moved_to": "Denne besked er flyttet til {folder}.", + "window_not_ready": "Det nye vindue åbnede ikke i tide. Din kladde bliver her." } } diff --git a/locales/de/common.json b/locales/de/common.json index 5ba043d71..33658a029 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6498,6 +6498,7 @@ "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." + "message_moved_to": "Diese Nachricht wurde nach {folder} verschoben.", + "window_not_ready": "Das neue Fenster wurde nicht rechtzeitig geöffnet. Ihr Entwurf bleibt hier." } } diff --git a/locales/en/common.json b/locales/en/common.json index d0d3893e1..2f85ebee1 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6498,6 +6498,7 @@ "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}." + "message_moved_to": "This message was moved to {folder}.", + "window_not_ready": "The new window did not open in time. Your draft stays here." } } diff --git a/locales/es/common.json b/locales/es/common.json index 06791d723..2fd90d3ae 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6498,6 +6498,7 @@ "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}." + "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í." } } diff --git a/locales/et/common.json b/locales/et/common.json index 1b71e356b..9d4420c55 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6498,6 +6498,7 @@ "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}." + "message_moved_to": "See kiri teisaldati kausta {folder}.", + "window_not_ready": "Uus aken ei avanenud õigeks ajaks. Mustand jääb siia." } } diff --git a/locales/fa/common.json b/locales/fa/common.json index 0e34826d5..9d50a6570 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6498,6 +6498,7 @@ "message_gone_title": "این پیام دیگر وجود ندارد", "message_gone_body": "حذف شده یا به حساب دیگری منتقل شده است.", "message_moved": "این پیام به پوشهٔ دیگری منتقل شد.", - "message_moved_to": "این پیام به {folder} منتقل شد." + "message_moved_to": "این پیام به {folder} منتقل شد.", + "window_not_ready": "پنجرهٔ جدید به‌موقع باز نشد. پیش‌نویس شما همین‌جا می‌ماند." } } diff --git a/locales/fr/common.json b/locales/fr/common.json index a8d63c5c4..61303cd3b 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6498,6 +6498,7 @@ "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}." + "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." } } diff --git a/locales/he/common.json b/locales/he/common.json index fc2684187..a9c364ce4 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6499,6 +6499,7 @@ "message_gone_title": "ההודעה הזו כבר לא קיימת", "message_gone_body": "היא נמחקה או הועברה לחשבון אחר.", "message_moved": "ההודעה הזו הועברה לתיקייה אחרת.", - "message_moved_to": "ההודעה הזו הועברה אל {folder}." + "message_moved_to": "ההודעה הזו הועברה אל {folder}.", + "window_not_ready": "החלון החדש לא נפתח בזמן. הטיוטה נשארת כאן." } } diff --git a/locales/hu/common.json b/locales/hu/common.json index 43cd7b8cc..af3cf17a4 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6498,6 +6498,7 @@ "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}." + "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." } } diff --git a/locales/it/common.json b/locales/it/common.json index 8e9df1dd1..b0ec7f41c 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6498,6 +6498,7 @@ "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}." + "message_moved_to": "Questo messaggio è stato spostato in {folder}.", + "window_not_ready": "La nuova finestra non si è aperta in tempo. La bozza rimane qui." } } diff --git a/locales/ja/common.json b/locales/ja/common.json index 427d67e6b..4510421dc 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6498,6 +6498,7 @@ "message_gone_title": "このメッセージはもうありません", "message_gone_body": "削除されたか、別のアカウントに移動されました。", "message_moved": "このメッセージは別のフォルダーに移動されました。", - "message_moved_to": "このメッセージは {folder} に移動されました。" + "message_moved_to": "このメッセージは {folder} に移動されました。", + "window_not_ready": "新しいウィンドウが時間内に開きませんでした。下書きはここに残ります。" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index c4f715426..02308e15f 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6498,6 +6498,7 @@ "message_gone_title": "이 메시지는 더 이상 없습니다", "message_gone_body": "삭제되었거나 다른 계정으로 이동되었습니다.", "message_moved": "이 메시지는 다른 폴더로 이동되었습니다.", - "message_moved_to": "이 메시지는 {folder}(으)로 이동되었습니다." + "message_moved_to": "이 메시지는 {folder}(으)로 이동되었습니다.", + "window_not_ready": "새 창이 제때 열리지 않았습니다. 임시 보관 메일은 여기에 남아 있습니다." } } diff --git a/locales/lv/common.json b/locales/lv/common.json index 456d20c2f..68f0be120 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6498,6 +6498,7 @@ "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}." + "message_moved_to": "Šis ziņojums tika pārvietots uz {folder}.", + "window_not_ready": "Jaunais logs neatvērās laikus. Melnraksts paliek šeit." } } diff --git a/locales/mn/common.json b/locales/mn/common.json index eb0e6dcd5..9fbfd663a 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6498,6 +6498,7 @@ "message_gone_title": "Энэ мессеж байхгүй болсон", "message_gone_body": "Устгагдсан эсвэл өөр бүртгэл рүү зөөгдсөн.", "message_moved": "Энэ мессежийг өөр хавтас руу зөөсөн.", - "message_moved_to": "Энэ мессежийг {folder} руу зөөсөн." + "message_moved_to": "Энэ мессежийг {folder} руу зөөсөн.", + "window_not_ready": "Шинэ цонх цагтаа нээгдсэнгүй. Таны ноорог энд үлдэнэ." } } diff --git a/locales/nb/common.json b/locales/nb/common.json index 42e370dec..34985ec53 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6498,6 +6498,7 @@ "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}." + "message_moved_to": "Denne meldingen ble flyttet til {folder}.", + "window_not_ready": "Det nye vinduet åpnet seg ikke i tide. Utkastet blir her." } } diff --git a/locales/nl/common.json b/locales/nl/common.json index 6dd627664..24c7b2a66 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6498,6 +6498,7 @@ "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}." + "message_moved_to": "Dit bericht is verplaatst naar {folder}.", + "window_not_ready": "Het nieuwe venster is niet op tijd geopend. Je concept blijft hier." } } diff --git a/locales/pl/common.json b/locales/pl/common.json index b3aae3664..7223b4ba1 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6498,6 +6498,7 @@ "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}." + "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." } } diff --git a/locales/pt/common.json b/locales/pt/common.json index c7a29368c..13cca1148 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6498,6 +6498,7 @@ "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}." + "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." } } diff --git a/locales/ro/common.json b/locales/ro/common.json index d40ff3966..3bf4a5af9 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6498,6 +6498,7 @@ "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}." + "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." } } diff --git a/locales/ru/common.json b/locales/ru/common.json index 62bdd8add..9ded598e7 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6498,6 +6498,7 @@ "message_gone_title": "Этого письма больше нет", "message_gone_body": "Оно удалено или перемещено в другую учётную запись.", "message_moved": "Это письмо перемещено в другую папку.", - "message_moved_to": "Это письмо перемещено в {folder}." + "message_moved_to": "Это письмо перемещено в {folder}.", + "window_not_ready": "Новое окно не открылось вовремя. Черновик остаётся здесь." } } diff --git a/locales/sk/common.json b/locales/sk/common.json index 92deb06b3..9ae6276cd 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6498,6 +6498,7 @@ "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}." + "message_moved_to": "Táto správa bola presunutá do {folder}.", + "window_not_ready": "Nové okno sa neotvorilo včas. Koncept zostáva tu." } } diff --git a/locales/th/common.json b/locales/th/common.json index f01cb6abf..90aae511b 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6506,6 +6506,7 @@ "message_gone_title": "ไม่มีข้อความนี้แล้ว", "message_gone_body": "ข้อความถูกลบหรือย้ายไปยังบัญชีอื่น", "message_moved": "ข้อความนี้ถูกย้ายไปยังโฟลเดอร์อื่น", - "message_moved_to": "ข้อความนี้ถูกย้ายไปยัง {folder}" + "message_moved_to": "ข้อความนี้ถูกย้ายไปยัง {folder}", + "window_not_ready": "หน้าต่างใหม่เปิดไม่ทัน แบบร่างของคุณยังอยู่ที่นี่" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index ba9bf2aff..bfe1c884c 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6498,6 +6498,7 @@ "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ı." + "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." } } diff --git a/locales/uk/common.json b/locales/uk/common.json index 267821950..4f1ce8ea1 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6498,6 +6498,7 @@ "message_gone_title": "Цього листа більше немає", "message_gone_body": "Його видалено або переміщено в інший обліковий запис.", "message_moved": "Цього листа переміщено в іншу папку.", - "message_moved_to": "Цього листа переміщено в {folder}." + "message_moved_to": "Цього листа переміщено в {folder}.", + "window_not_ready": "Нове вікно не відкрилося вчасно. Чернетка залишається тут." } } diff --git a/locales/vi/common.json b/locales/vi/common.json index 2dc0d5f06..0022c228b 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6510,6 +6510,7 @@ "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}." + "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." } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 8fc2a36ae..1a52c4a4c 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6498,6 +6498,7 @@ "message_gone_title": "這封郵件已不存在", "message_gone_body": "它已被刪除或移到其他帳戶。", "message_moved": "這封郵件已移到其他資料夾。", - "message_moved_to": "這封郵件已移到 {folder}。" + "message_moved_to": "這封郵件已移到 {folder}。", + "window_not_ready": "新視窗未能及時開啟。你的草稿仍保留在這裡。" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index 35649d2d5..4d7cf20aa 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6498,6 +6498,7 @@ "message_gone_title": "这封邮件已不存在", "message_gone_body": "它已被删除或移动到其他账户。", "message_moved": "这封邮件已移动到其他文件夹。", - "message_moved_to": "这封邮件已移动到 {folder}。" + "message_moved_to": "这封邮件已移动到 {folder}。", + "window_not_ready": "新窗口未能及时打开。你的草稿仍保留在这里。" } } diff --git a/stores/__tests__/auth-store-shared-session.test.ts b/stores/__tests__/auth-store-shared-session.test.ts index 19dcb2cbb..c8bad00ec 100644 --- a/stores/__tests__/auth-store-shared-session.test.ts +++ b/stores/__tests__/auth-store-shared-session.test.ts @@ -57,8 +57,10 @@ vi.mock('@/lib/jmap/client', () => { 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, @@ -69,14 +71,14 @@ const account = (id: string, slot: number, over: Record<string, unknown> = {}) = let fetched: Array<{ url: string; method?: string }>; let tokenAnswers: Response[]; let main: PopoutChannel | null; -let requests: Array<{ accountId?: string; rejectedAuthHeader?: string }>; +let requests: Array<{ accountId?: string; rejectedFingerprint?: string }>; -function startMainWindow(answer: (request: { accountId?: string; rejectedAuthHeader?: string }) => { activeAccountId: string; sessions: unknown[] } | null) { +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, rejectedAuthHeader: message.rejectedAuthHeader }); + requests.push({ accountId: message.accountId, rejectedFingerprint: message.rejectedFingerprint }); const result = answer(message); - if (result) main?.post({ type: 'session-answer', requestId: message.requestId, answer: result as never }); + if (result) void sealFor(message.publicKey, result).then((sealed) => main?.post({ type: 'session-answer', requestId: message.requestId, sealed })); }); } @@ -135,6 +137,13 @@ describe('startSharedSession', () => { 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')] })); @@ -186,7 +195,7 @@ describe('startSharedSession', () => { describe('a token the server refused in a pop-out', () => { async function started() { startMainWindow((request) => { - const header = request.rejectedAuthHeader ? 'Bearer renewed-by-main' : 'Bearer main-token'; + const header = request.rejectedFingerprint ? 'Bearer renewed-by-main' : 'Bearer main-token'; return { activeAccountId: 'alice', sessions: [session('alice', header)] }; }); await useAuthStore.getState().startSharedSession(); @@ -196,7 +205,7 @@ describe('a token the server refused in a pop-out', () => { 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', rejectedAuthHeader: 'Bearer main-token' }); + expect(requests[1]).toEqual({ accountId: 'alice', rejectedFingerprint: await credentialFingerprint('Bearer main-token') }); expect(tokenCalls()).toEqual([]); }); @@ -213,7 +222,7 @@ describe('a token the server refused in a pop-out', () => { const refresh = await started(); main?.close(); main = null; - const locks = { request: vi.fn((_name: string, _options: object, callback: () => Promise<unknown>) => callback()) }; + 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'); @@ -251,7 +260,7 @@ describe('a token the server refused in a pop-out', () => { 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: () => Promise<unknown>) => callback()) }; + 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 }); @@ -260,4 +269,83 @@ describe('renewals in a main window', () => { } 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'); + 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/auth-store.ts b/stores/auth-store.ts index 5c3e6e1c7..da1352970 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -614,32 +614,37 @@ async function noteSessionAnswer(res: Response, slot: number | undefined): Promi * that ends with the browser session. */ function fetchSlotAccessToken(slot: number, opts: { force?: boolean; remember?: boolean } = {}): Promise<Response> { - // One at a time across windows: each must send the refresh token the last - // answer wrote (lib/auth/token-lock.ts). - return withTokenLock(slot, async () => { - // Renewing would write the tokens of a slot being signed out back. + // Renewing would write the tokens of a slot being signed out back. + 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 = `${opts.force ? '&force=true' : ''}${opts.remember === false ? '&remember=false' : ''}`; - return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${query}`, { method: 'PUT', signal })) + 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 (!opts.force) { + if (!force) { const cached = readLiteAccessToken(slot); if (cached) return jsonResponse({ access_token: cached.accessToken, expires_in: cached.expiresIn }); } - return trackSessionWrite(slot, async () => { - try { - const tokens = await liteRefreshTokens(slot); - return jsonResponse({ access_token: tokens.accessToken, expires_in: tokens.expiresIn }); - } catch (err) { - if (err instanceof LiteLoginError) return jsonResponse({ error: err.code }, liteErrorStatus(err)); - throw err; // network failure - callers treat it as an outage, not a rejection - } - }); - }); + try { + const tokens = await liteRefreshTokens(slot); + return jsonResponse({ access_token: tokens.accessToken, expires_in: tokens.expiresIn }); + } catch (err) { + 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. */ @@ -1135,9 +1140,6 @@ function snapshotAndClearForSwitch(activeAccountId: string | null): void { const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>(); const refreshPromises = new Map<string, Promise<string | null>>(); -/** This window is a pop-out on another window's session (startSharedSession). */ -let sharedSessionWindow = false; - /** 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'; @@ -2239,7 +2241,8 @@ export const useAuthStore = create<AuthState>()( return refreshPromise; } - if (sharedSessionWindow) { + // 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); @@ -2248,7 +2251,6 @@ export const useAuthStore = create<AuthState>()( 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; @@ -2260,6 +2262,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 @@ -3353,10 +3361,9 @@ export const useAuthStore = create<AuthState>()( }, startSharedSession: async (options) => { - sharedSessionWindow = true; set({ isLoading: true, client: null }); const accountStore = useAccountStore.getState(); - const answer = await requestWindowSession(); + 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 @@ -3540,6 +3547,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(); }); } From 55ca12adbde3ef789b7d5bc420f41d11430f4687 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:43:35 +0100 Subject: [PATCH 13/27] fix: say when a start stays on the local copy, queue its sends, paint it only with folders kept and fill the From list after the swap --- lib/jmap/offline-hooks.ts | 2 + lib/offline/__tests__/review-fixes.test.ts | 23 +++++++ lib/offline/offline-mode.ts | 12 +++- lib/offline/state.ts | 15 +++++ lib/store/__tests__/mail-answers.test.ts | 26 ++++++++ lib/store/mail/calls.ts | 2 + lib/store/mail/methods.ts | 3 + .../__tests__/auth-store-local-copy.test.ts | 66 ++++++++++++++++++- stores/auth-store.ts | 19 +++++- 9 files changed, 160 insertions(+), 8 deletions(-) diff --git a/lib/jmap/offline-hooks.ts b/lib/jmap/offline-hooks.ts index b908eafb7..753b4273d 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 { diff --git a/lib/offline/__tests__/review-fixes.test.ts b/lib/offline/__tests__/review-fixes.test.ts index c516f61e5..11bd3cfd5 100644 --- a/lib/offline/__tests__/review-fixes.test.ts +++ b/lib/offline/__tests__/review-fixes.test.ts @@ -28,6 +28,7 @@ import { flushQueue, settleStaleSends, SENDING_STALE_MS } from '../flush'; import { claimForSending, holdFile, queueSend, readQueue } from '../queue'; import { forgetLegacyMove } 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'; @@ -283,6 +284,28 @@ describe('11: starting from the device', () => { writeEnabled(['acc']); expect(await offlineSessionFor('acc')).toMatchObject({ primaryAccounts: { 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT } }); }); + + 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('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/offline-mode.ts b/lib/offline/offline-mode.ts index 03b0e140d..4ad4994f8 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -54,7 +54,7 @@ import { type PolicyFingerprint, } from './queue'; import { clearHandoffs } from '@/lib/launch-handoff'; -import { clearEvicted, EVICTED_KEEP_MS, isEvicted, isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, readEvicted, visibleAccounts, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state'; +import { clearEvicted, EVICTED_KEEP_MS, forgetMailSnapshot, isEvicted, isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, readEvicted, visibleAccounts, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state'; import { flushQueue, settleStaleSends } from './flush'; import { LEGACY_DB_NAME, moveLegacyOutbox } from './migrate-legacy'; import { deleteShellCaches, tellServiceWorker } from './shell'; @@ -160,6 +160,7 @@ const storeHooks: StorePageHooks = { onEvent(event) { if (event.type === 'synced') { useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at } })); + forgetMailSnapshot(event.account); void refreshUsage(); } if (event.type === 'error' && event.area === STORAGE_ERROR_AREA) { @@ -392,6 +393,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) { @@ -981,7 +984,7 @@ 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> { +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(); @@ -992,7 +995,10 @@ export async function offlineSessionFor(account: string): Promise<unknown | null try { await 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/state.ts b/lib/offline/state.ts index 4508c07e5..5a86ca647 100644 --- a/lib/offline/state.ts +++ b/lib/offline/state.ts @@ -119,6 +119,21 @@ export function visibleAccounts(): string[] { 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/store/__tests__/mail-answers.test.ts b/lib/store/__tests__/mail-answers.test.ts index 90a36a9b8..6461af6f4 100644 --- a/lib/store/__tests__/mail-answers.test.ts +++ b/lib/store/__tests__/mail-answers.test.ts @@ -263,6 +263,32 @@ 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(); diff --git a/lib/store/mail/calls.ts b/lib/store/mail/calls.ts index 429b55dd5..5916c7ce6 100644 --- a/lib/store/mail/calls.ts +++ b/lib/store/mail/calls.ts @@ -59,4 +59,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 43c8c87a1..8462563e4 100644 --- a/lib/store/mail/methods.ts +++ b/lib/store/mail/methods.ts @@ -317,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, 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/auth-store.ts b/stores/auth-store.ts index c891ab06c..1fae88b7b 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1368,7 +1368,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; @@ -2916,7 +2917,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}`); @@ -2994,6 +2995,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; @@ -3006,6 +3010,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 @@ -3050,6 +3063,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 }); }) From 074d42e62a12450f9807f248412a373414a8b689 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:43:36 +0100 Subject: [PATCH 14/27] fix: stop keeping a plain mail list for accounts in the store and drop a stale answer from a replaced client --- .../__tests__/email-snapshot-account.test.ts | 66 +++++++++++++++++++ stores/email-store.ts | 6 ++ 2 files changed, 72 insertions(+) diff --git a/stores/__tests__/email-snapshot-account.test.ts b/stores/__tests__/email-snapshot-account.test.ts index a7d2c4e4f..c2ee1933d 100644 --- a/stores/__tests__/email-snapshot-account.test.ts +++ b/stores/__tests__/email-snapshot-account.test.ts @@ -38,6 +38,72 @@ describe('email boot snapshot', { timeout: 30_000 }, () => { }); }); +// 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 fresh(account: string) { + vi.useFakeTimers(); + persistSession(account); + vi.resetModules(); + const { useEmailStore } = await import('@/stores/email-store'); + const offline = await import('@/lib/offline/state'); + return { useEmailStore, offline }; + } + + 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(); + }); + + 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(); + offline.forgetMailSnapshot('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([]); + }); +}); + // The snapshot keeps the last plain folder that was open; a session that // ended in a unified view left Trash there, and every visit to "/" opened it. describe('email boot snapshot folder', { timeout: 30_000 }, () => { diff --git a/stores/email-store.ts b/stores/email-store.ts index 0f1b60161..8bff6f55d 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -38,6 +38,7 @@ import { SendCancelledCleanupError } from "@/lib/send-failure-message"; import type { StorageQuota } from "@/lib/quota"; import { isArchiveActionEnabled } from "@/lib/archive-gate"; import { getOutboxHooks } from "@/lib/offline/outbox-hooks"; +import { useOfflineStore } from "@/lib/offline/state"; import { READ_FILTER, UNREAD_FILTER, UNREAD_SECTION_SIZE, andFilters, isUnreadFirstView, mergeUnreadSection, openSectionRows, sectionFromUnreadRows, unreadSectionRowCount, withoutReached, type UnreadSection, @@ -1626,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 @@ -8728,6 +8732,8 @@ if (typeof window !== 'undefined') { if (s.searchQuery || s.selectedKeyword || s.isScheduledView || s.viewingAccountId || s.isUnifiedView) return; const account = persistedAuthState()?.activeAccountId; 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, From cdda75d13b79eb20e5396608d773a635b0fbfc03 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:49:53 +0100 Subject: [PATCH 15/27] fix: keep queued changes and mail through rate limits, logins that need renewing and proxy errors, and show the server's copy after a real refusal --- lib/offline/__tests__/review-fixes.test.ts | 62 +++++++++++++++++++++- lib/offline/flush.ts | 49 ++++++++++++----- lib/store/__tests__/outbox.test.ts | 53 +++++++++++++++--- lib/store/outbox/flush-patches.ts | 50 +++++++++++++---- 4 files changed, 183 insertions(+), 31 deletions(-) diff --git a/lib/offline/__tests__/review-fixes.test.ts b/lib/offline/__tests__/review-fixes.test.ts index 11bd3cfd5..3801d334e 100644 --- a/lib/offline/__tests__/review-fixes.test.ts +++ b/lib/offline/__tests__/review-fixes.test.ts @@ -25,7 +25,7 @@ import { wipeEverything, } from '../offline-mode'; import { flushQueue, settleStaleSends, SENDING_STALE_MS } from '../flush'; -import { claimForSending, holdFile, queueSend, readQueue } from '../queue'; +import { claimForSending, holdFile, LOCAL_BLOB_PREFIX, queueSend, readQueue, updateOutboxEntry } from '../queue'; import { forgetLegacyMove } from '../migrate-legacy'; import { consumeLost } from '../lost'; import { getOutboxHooks } from '../outbox-hooks'; @@ -295,6 +295,66 @@ describe('11: starting from the device', () => { }); }); +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 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('a send on the local copy', () => { it('queues, before the swap too, while the probes say the server is up', () => { writeEnabled(['acc']); diff --git a/lib/offline/flush.ts b/lib/offline/flush.ts index 53e250173..291004300 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'; @@ -72,6 +74,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 +108,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> { @@ -153,12 +176,8 @@ export async function flushQueue(account: string, client: JMAPClient, deps: Flus /** 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(); - await Promise.all(accounts.map((id) => getStore().area(id, 'outbox.settleStale', { before: now - SENDING_STALE_MS }))); -} - -/** 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); + // 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 +186,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 }); @@ -271,8 +289,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; diff --git a/lib/store/__tests__/outbox.test.ts b/lib/store/__tests__/outbox.test.ts index 0edf2cc39..af80990e5 100644 --- a/lib/store/__tests__/outbox.test.ts +++ b/lib/store/__tests__/outbox.test.ts @@ -62,9 +62,8 @@ async function boxes() { 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()) { @@ -358,16 +357,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 () => { 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); } From 3b1fa089aa61ee94a4653a1e397eb6129b9d3f21 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:54:05 +0100 Subject: [PATCH 16/27] test: give the stand-in desktop bridge its version and platform --- components/popout/__tests__/popout-host.test.tsx | 2 +- lib/__tests__/desktop-window-sync.test.ts | 4 ++-- lib/__tests__/popout-windows.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx index a7629b043..4defd25c8 100644 --- a/components/popout/__tests__/popout-host.test.tsx +++ b/components/popout/__tests__/popout-host.test.tsx @@ -55,7 +55,7 @@ beforeEach(() => { FakeBroadcastChannel.reset(); vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); // The bridge makes pop-outs available without stubbing the display mode. - window.bulwarkDesktop = { windows: {} }; + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: {} }; stubLocks(); sessionStorage.clear(); heard = []; diff --git a/lib/__tests__/desktop-window-sync.test.ts b/lib/__tests__/desktop-window-sync.test.ts index 260c8f788..7ff38a7fc 100644 --- a/lib/__tests__/desktop-window-sync.test.ts +++ b/lib/__tests__/desktop-window-sync.test.ts @@ -72,7 +72,7 @@ describe('state across windows', () => { }); it('also runs in the desktop app\'s main window', async () => { - window.bulwarkDesktop = { windows: {} }; + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: {} }; stop = startWindowSync(); writeFromAnotherWindow('locale-storage', { locale: 'fr' }); await settle(); @@ -140,7 +140,7 @@ describe('the app-like behaviour', () => { }); it('marks the page before the first paint when the bridge is there', () => { - window.bulwarkDesktop = {}; + window.bulwarkDesktop = { version: 1, platform: 'win32' }; runBoot(); expect(document.documentElement.hasAttribute('data-desktop')).toBe(true); }); diff --git a/lib/__tests__/popout-windows.test.ts b/lib/__tests__/popout-windows.test.ts index 0832f163e..f3675c552 100644 --- a/lib/__tests__/popout-windows.test.ts +++ b/lib/__tests__/popout-windows.test.ts @@ -101,7 +101,7 @@ describe('popoutsAvailable', () => { it('asks the bridge too: display-mode is `browser` in Electron', async () => { stubDisplay({ standalone: false }); - window.bulwarkDesktop = { windows: {} }; + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: {} }; expect(await available()).toBe(true); }); @@ -168,7 +168,7 @@ describe('opening windows', () => { 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 = { windows: { open: bridgeOpen } }; + 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); From 62a47434e39e21cf5299514028defe04e961254a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:56:30 +0100 Subject: [PATCH 17/27] fix: ask before a sign-out about everything unsent, including changes, and say when the local store won't open --- components/offline/offline-host.tsx | 17 +++-- lib/offline/__tests__/review-fixes.test.ts | 77 +++++++++++++++++++++- lib/offline/offline-mode.ts | 41 +++++++++++- lib/offline/queue.ts | 9 ++- lib/store/calendar/index.ts | 2 + locales/ar/common.json | 9 ++- locales/ca/common.json | 9 ++- locales/cs/common.json | 9 ++- locales/da/common.json | 9 ++- locales/de/common.json | 9 ++- locales/en/common.json | 9 ++- locales/es/common.json | 9 ++- locales/et/common.json | 9 ++- locales/fa/common.json | 9 ++- locales/fr/common.json | 9 ++- locales/he/common.json | 9 ++- locales/hu/common.json | 9 ++- locales/it/common.json | 9 ++- locales/ja/common.json | 9 ++- locales/ko/common.json | 9 ++- locales/lv/common.json | 9 ++- locales/mn/common.json | 9 ++- locales/nb/common.json | 9 ++- locales/nl/common.json | 9 ++- locales/pl/common.json | 9 ++- locales/pt/common.json | 9 ++- locales/ro/common.json | 9 ++- locales/ru/common.json | 9 ++- locales/sk/common.json | 9 ++- locales/th/common.json | 9 ++- locales/tr/common.json | 9 ++- locales/uk/common.json | 9 ++- locales/vi/common.json | 9 ++- locales/zh-TW/common.json | 9 ++- locales/zh/common.json | 9 ++- 35 files changed, 376 insertions(+), 40 deletions(-) diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx index 13e675950..d88a371c1 100644 --- a/components/offline/offline-host.tsx +++ b/components/offline/offline-host.tsx @@ -6,7 +6,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; import { addSignOutGate } from '@/lib/sign-out-cleanup'; import { reloadToNewBuild } from '@/lib/app-update'; -import { confirmLoss, setOfflineNotifier, startOfflineMode, type OfflineNotice } from '@/lib/offline/offline-mode'; +import { confirmLoss, setOfflineNotifier, startOfflineMode, unsentCount, type OfflineNotice } from '@/lib/offline/offline-mode'; import { useOfflineStore } from '@/lib/offline/state'; import { toast } from '@/stores/toast-store'; @@ -54,6 +54,9 @@ export function OfflineHost() { case 'storage-failed': toast.warning(tConflictsRef.current('storage_failed'), { duration: 0 }); break; + case 'store-unavailable': + toast.warning(tConflictsRef.current('store_unavailable'), { duration: 0 }); + break; case 'refused-delete': toast.warning(tr('toast.refused_delete')); break; @@ -75,12 +78,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/lib/offline/__tests__/review-fixes.test.ts b/lib/offline/__tests__/review-fixes.test.ts index 3801d334e..ce5e06d6a 100644 --- a/lib/offline/__tests__/review-fixes.test.ts +++ b/lib/offline/__tests__/review-fixes.test.ts @@ -20,8 +20,11 @@ import { enforcePolicy, offlineSessionFor, pluginsNotRunning, + refreshQueueState, + setOfflineNotifier, startOfflineMode, stricterNow, + unsentCount, wipeEverything, } from '../offline-mode'; import { flushQueue, settleStaleSends, SENDING_STALE_MS } from '../flush'; @@ -30,7 +33,7 @@ import { forgetLegacyMove } 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 { isOfflineNow, OFFLINE_ACCOUNTS_KEY, OFFLINE_EVICTED_KEY, setServerDown, useOfflineStore, writeEnabled } from '../state'; function fakeClient(server = 'https://mail.example.org', overrides: Record<string, unknown> = {}) { return { @@ -295,6 +298,78 @@ describe('11: starting from the device', () => { }); }); +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 }); + vi.spyOn(store, 'area').mockImplementation(() => new Promise(() => {})); + expect(await unsentCount('acc', 10)).toEqual({ messages: 1, changes: 2 }); + }); + + 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']); diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 4ad4994f8..30a47ee24 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -68,6 +68,7 @@ export type OfflineNotice = | { kind: 'lost'; subjects: string[] } | { kind: 'conflict'; count: number } | { kind: 'storage-failed' } + | { kind: 'store-unavailable' } | { kind: 'refused-delete' } | { kind: 'refused-other' } | { kind: 'forbidden' } @@ -140,6 +141,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) { @@ -482,7 +493,8 @@ export async function refreshQueueState(): Promise<void> { try { // Sends of a tab closed on the way: uncertain, so they can be looked up. await settleStaleSends(); - const { patches, outbox: entries, conflicts } = await readQueue(); + const { failed, patches, outbox: entries, conflicts } = await readQueue(); + if (failed.length > 0) noteStoreUnavailable(); const rows: OutboxRow[] = entries.map((entry) => ({ id: entry.id, account: entry.account, @@ -492,9 +504,12 @@ export async function refreshQueueState(): Promise<void> { status: entry.status, error: entry.error, })); - useOfflineStore.setState({ outbox: rows, pendingChanges: patches, 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)); + useOfflineStore.setState({ outbox: [...unread, ...rows], pendingChanges: patches, conflicts }); } catch { // The store is being deleted, or can't start. + noteStoreUnavailable(); } } @@ -683,6 +698,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; diff --git a/lib/offline/queue.ts b/lib/offline/queue.ts index fccd14044..b9e0232be 100644 --- a/lib/offline/queue.ts +++ b/lib/offline/queue.ts @@ -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; outbox: OutboxEntry[]; conflicts: ConflictEntry[] }> { +export async function readQueue(account?: string): Promise<{ failed: string[]; patches: number; outbox: OutboxEntry[]; conflicts: ConflictEntry[] }> { const accounts = account ? [account] : visibleAccounts(); - const read = await Promise.all(accounts.map(async (id) => ({ id, queue: await getStore().area<StoredQueue>(id, 'outbox.read') }))); + 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), outbox: read.flatMap(({ id, queue }) => queue.entries.map((entry) => ({ ...entry, account: id }))), conflicts: read.flatMap(({ id, queue }) => queue.conflicts.map((conflict) => ({ diff --git a/lib/store/calendar/index.ts b/lib/store/calendar/index.ts index 12d908660..1401c050e 100644 --- a/lib/store/calendar/index.ts +++ b/lib/store/calendar/index.ts @@ -11,6 +11,8 @@ export const calendarArea: StoreArea = { syncers: [calendarSyncer], calls: { 'calendar.flush': (ctx) => flushCalendar(ctx), + /** 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 }) => { diff --git a/locales/ar/common.json b/locales/ar/common.json index ae8e0aa58..0b6b9fc22 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": "خادم البريد لا يستجيب. حاول مرة أخرى بعد قليل." }, "deep_link": { "message_not_found": "لم تعد هذه الرسالة متاحة.", diff --git a/locales/ca/common.json b/locales/ca/common.json index ce62318b7..a55cd3b3a 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." }, "deep_link": { "message_not_found": "Aquest missatge ja no està disponible.", diff --git a/locales/cs/common.json b/locales/cs/common.json index a37357845..eb1675dee 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." }, "deep_link": { "message_not_found": "Tato zpráva už není dostupná.", diff --git a/locales/da/common.json b/locales/da/common.json index b024c725c..7aecd1273 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." }, "deep_link": { "message_not_found": "Denne besked er ikke længere tilgængelig.", diff --git a/locales/de/common.json b/locales/de/common.json index 2dc411640..f3cc9bec2 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." }, "deep_link": { "message_not_found": "Diese Nachricht ist nicht mehr verfügbar.", diff --git a/locales/en/common.json b/locales/en/common.json index fdb1eac13..9f0c89daf 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." }, "deep_link": { "message_not_found": "This message is no longer available.", diff --git a/locales/es/common.json b/locales/es/common.json index 7b90943c5..1a33b8cdc 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." }, "deep_link": { "message_not_found": "Este mensaje ya no está disponible.", diff --git a/locales/et/common.json b/locales/et/common.json index a6b5b5eb2..bc4fd6bfe 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." }, "deep_link": { "message_not_found": "See sõnum ei ole enam saadaval.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 270c6c3a8..74925e523 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": "سرور ایمیل پاسخ نمی‌دهد. کمی بعد دوباره امتحان کنید." }, "deep_link": { "message_not_found": "این پیام دیگر در دسترس نیست.", diff --git a/locales/fr/common.json b/locales/fr/common.json index dae94c9c7..789a3abb8 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." }, "deep_link": { "message_not_found": "Ce message n'est plus disponible.", diff --git a/locales/he/common.json b/locales/he/common.json index 6053b7fc7..c2eec91d6 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": "שרת הדואר לא עונה. אפשר לנסות שוב בעוד רגע." }, "deep_link": { "message_not_found": "הודעה זו אינה זמינה עוד.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 07cca9946..b80a2f025 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." }, "deep_link": { "message_not_found": "Ez az üzenet már nem érhető el.", diff --git a/locales/it/common.json b/locales/it/common.json index 398c64b80..e7b190691 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." }, "deep_link": { "message_not_found": "Questo messaggio non è più disponibile.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7f5688704..9f3ce1553 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": "メールサーバーが応答していません。少ししてからもう一度お試しください。" }, "deep_link": { "message_not_found": "このメッセージは利用できなくなりました。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 4c06d6b3f..f55e28718 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": "메일 서버가 응답하지 않습니다. 잠시 후 다시 시도하세요." }, "deep_link": { "message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index 8ebac9b3b..08d753d84 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." }, "deep_link": { "message_not_found": "Šī ziņa vairs nav pieejama.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 43edeb367..49819db84 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": "Шуудангийн сервер хариу өгөхгүй байна. Хэсэг хугацааны дараа дахин оролдоно уу." }, "deep_link": { "message_not_found": "Энэ зурвас дахин ашиглах боломжгүй болсон.", diff --git a/locales/nb/common.json b/locales/nb/common.json index 267f73a56..247c01774 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." }, "deep_link": { "message_not_found": "Denne meldingen er ikke lenger tilgjengelig.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 1cf1cabbb..f1cb0eaa8 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." }, "deep_link": { "message_not_found": "Dit bericht is niet meer beschikbaar.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 7067bbaaa..eacdb2dfc 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ę." }, "deep_link": { "message_not_found": "Ta wiadomość nie jest już dostępna.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 4bce29dd5..6286d9e9b 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." }, "deep_link": { "message_not_found": "Esta mensagem não está mais disponível.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 17abb3f3a..24588de84 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." }, "deep_link": { "message_not_found": "Acest mesaj nu mai este disponibil.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 9d50c9b45..1dff04654 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": "Почтовый сервер не отвечает. Повторите попытку через минуту." }, "deep_link": { "message_not_found": "Это письмо больше недоступно.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 13b2e0ae2..242403470 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." }, "deep_link": { "message_not_found": "Táto správa už nie je dostupná.", diff --git a/locales/th/common.json b/locales/th/common.json index a1cb466d9..eb95ec4a5 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": "เซิร์ฟเวอร์อีเมลไม่ตอบสนอง โปรดลองอีกครั้งในอีกสักครู่" }, "plugin_pages": { "loading": "กำลังโหลดหน้าเว็บ...", diff --git a/locales/tr/common.json b/locales/tr/common.json index 839601428..7eb7b2040 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." }, "deep_link": { "message_not_found": "Bu ileti artık kullanılamıyor.", diff --git a/locales/uk/common.json b/locales/uk/common.json index e9ab2cd21..37e06eae5 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": "Поштовий сервер не відповідає. Спробуйте ще раз за хвилину." }, "deep_link": { "message_not_found": "Цей лист більше недоступний.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 2c242d359..8105c013c 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." }, "deep_link": { "message_not_found": "Thư này không còn nữa.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index dc8d0f4bf..3d2bacb4b 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": "郵件伺服器沒有回應。請稍後再試。" }, "deep_link": { "message_not_found": "已無法使用此郵件。", diff --git a/locales/zh/common.json b/locales/zh/common.json index bc7da9471..c7fa1d50b 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": "邮件服务器没有响应。请稍后再试。" }, "deep_link": { "message_not_found": "此邮件已不可用。", From 18efb9f4c33ffe0551f6c1e01f2edfcf99e54604 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:00:52 +0100 Subject: [PATCH 18/27] fix: wait 30 seconds before looking up a send, bound the waits at a start, and keep opened files only as the chosen scope allows --- components/offline/offline-host.tsx | 1 - lib/offline/__tests__/outbox.test.ts | 2 + lib/offline/__tests__/review-fixes.test.ts | 72 +++++++++++++++++++++- lib/offline/flush.ts | 7 +++ lib/offline/offline-mode.ts | 23 ++++--- lib/offline/state.ts | 13 ++++ lib/store/__tests__/mail-answers.test.ts | 20 ++++++ lib/store/__tests__/outbox.test.ts | 24 ++++++-- lib/store/client/api.ts | 10 +++ lib/store/mail/calls.ts | 1 + lib/store/outbox/calls.ts | 10 ++- lib/store/outbox/items.ts | 2 + lib/store/outbox/schema.ts | 9 +++ 13 files changed, 178 insertions(+), 16 deletions(-) diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx index d88a371c1..3287a4d29 100644 --- a/components/offline/offline-host.tsx +++ b/components/offline/offline-host.tsx @@ -7,7 +7,6 @@ import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; import { addSignOutGate } from '@/lib/sign-out-cleanup'; import { reloadToNewBuild } from '@/lib/app-update'; import { confirmLoss, setOfflineNotifier, startOfflineMode, unsentCount, type OfflineNotice } from '@/lib/offline/offline-mode'; -import { useOfflineStore } from '@/lib/offline/state'; import { toast } from '@/stores/toast-store'; /** diff --git a/lib/offline/__tests__/outbox.test.ts b/lib/offline/__tests__/outbox.test.ts index 03e61e04b..59fe37929 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 ce5e06d6a..93e28b9f2 100644 --- a/lib/offline/__tests__/review-fixes.test.ts +++ b/lib/offline/__tests__/review-fixes.test.ts @@ -27,13 +27,14 @@ import { unsentCount, wipeEverything, } from '../offline-mode'; -import { flushQueue, settleStaleSends, SENDING_STALE_MS } from '../flush'; +import { flushQueue, LOOKUP_WAIT_MS, settleStaleSends, SENDING_STALE_MS } from '../flush'; import { claimForSending, holdFile, LOCAL_BLOB_PREFIX, queueSend, readQueue, updateOutboxEntry } from '../queue'; import { forgetLegacyMove } from '../migrate-legacy'; import { consumeLost } from '../lost'; import { getOutboxHooks } from '../outbox-hooks'; import { shellFiles } from '../shell'; -import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, OFFLINE_EVICTED_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 { @@ -288,6 +289,41 @@ describe('11: starting from the device', () => { 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); @@ -430,6 +466,38 @@ describe('a send the server couldn\'t take right now', () => { }); }); +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']); diff --git a/lib/offline/flush.ts b/lib/offline/flush.ts index 291004300..0c21ef859 100644 --- a/lib/offline/flush.ts +++ b/lib/offline/flush.ts @@ -58,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; @@ -225,6 +228,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); diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 30a47ee24..96578ce2c 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -54,8 +54,8 @@ import { type PolicyFingerprint, } from './queue'; import { clearHandoffs } from '@/lib/launch-handoff'; -import { clearEvicted, EVICTED_KEEP_MS, forgetMailSnapshot, isEvicted, isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, readEvicted, visibleAccounts, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state'; -import { flushQueue, settleStaleSends } from './flush'; +import { clearEvicted, EVICTED_KEEP_MS, forgetMailSnapshot, isEvicted, offlineScopeFor, isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, readEvicted, visibleAccounts, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state'; +import { flushQueue, LOOKUP_WAIT_MS, settleStaleSends } from './flush'; import { LEGACY_DB_NAME, moveLegacyOutbox } from './migrate-legacy'; import { deleteShellCaches, tellServiceWorker } from './shell'; import { rememberLost } from './lost'; @@ -159,6 +159,7 @@ const storeHooks: StorePageHooks = { session: (client?.getSessionSnapshot() as StoreSession | undefined) ?? null, auth: client?.getAuthHeader() ?? null, invertedKeywordSort: client?.keywordSortInverted() ?? null, + scope: offlineScopeFor(account), }; }, async refreshAuth(account) { @@ -571,7 +572,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 { @@ -1021,16 +1022,24 @@ 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. */ +/** 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([ (async () => { if (options.withFolders && !(await getStore().area<boolean>(account, 'mail.hasFolders'))) return null; diff --git a/lib/offline/state.ts b/lib/offline/state.ts index 5a86ca647..edada7809 100644 --- a/lib/offline/state.ts +++ b/lib/offline/state.ts @@ -7,6 +7,7 @@ */ import { create } from 'zustand'; +import { DEFAULT_SCOPE, type StoreScope } from '@/lib/store/area'; import type { ConflictEntry, OutboxEntry, OutboxError } from './queue'; /** localStorage: the local account ids that keep mail on this device. */ @@ -74,6 +75,18 @@ export function writeEnabled(enabled: string[]): void { } } +/** localStorage: what each account chose to keep offline, on top of the defaults. */ +export const OFFLINE_SCOPE_KEY = 'bulwark-offline-scope'; + +export function offlineScopeFor(account: string): StoreScope { + try { + const chosen = (JSON.parse(localStorage.getItem(OFFLINE_SCOPE_KEY) ?? '{}') as Record<string, Partial<StoreScope>>)[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. */ diff --git a/lib/store/__tests__/mail-answers.test.ts b/lib/store/__tests__/mail-answers.test.ts index 6461af6f4..a361d6f53 100644 --- a/lib/store/__tests__/mail-answers.test.ts +++ b/lib/store/__tests__/mail-answers.test.ts @@ -471,6 +471,26 @@ describe('attachments', () => { expect(refused.downloads).toEqual(['b-gone']); }); + 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 af80990e5..2c8d5dbc6 100644 --- a/lib/store/__tests__/outbox.test.ts +++ b/lib/store/__tests__/outbox.test.ts @@ -34,7 +34,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 +43,7 @@ function setup() { let t: ReturnType<typeof setup>; beforeEach(async () => { + clock = NOW; t = setup(); await t.engine.requestSync(); }); @@ -57,6 +58,8 @@ 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]])); @@ -430,7 +433,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 () => { @@ -439,15 +442,28 @@ 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; + 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 () => { diff --git a/lib/store/client/api.ts b/lib/store/client/api.ts index f74b0f52e..09cb18af2 100644 --- a/lib/store/client/api.ts +++ b/lib/store/client/api.ts @@ -55,6 +55,8 @@ class PageStore implements StoreBackend { 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) {} @@ -90,6 +92,7 @@ class PageStore implements StoreBackend { private reset(): void { this.client = null; + this.sentScope.clear(); this.wanted.clear(); this.opening.clear(); this.broken = false; @@ -103,6 +106,7 @@ class PageStore implements StoreBackend { const client = this.started(); const { keyHex, record } = await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []); const inputs = this.hooks()?.inputs(account) ?? { session: null, auth: null, invertedKeywordSort: null }; + if (inputs.scope) this.sentScope.set(account, JSON.stringify(inputs.scope)); await client.openAccount({ account, file: record.file, @@ -132,6 +136,12 @@ class PageStore implements StoreBackend { if (!inputs) return; if (inputs.session) client.setSession(account, inputs.session); client.setAuth(account, inputs.auth); + // 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)) client.setScope(account, inputs.scope); + this.sentScope.set(account, scope!); + } if (inputs.invertedKeywordSort !== null) client.setInvertedKeywordSort(account, inputs.invertedKeywordSort); } diff --git a/lib/store/mail/calls.ts b/lib/store/mail/calls.ts index 5916c7ce6..713975810 100644 --- a/lib/store/mail/calls.ts +++ b/lib/store/mail/calls.ts @@ -34,6 +34,7 @@ function recordOpened(ctx: StoreContext, params: { jmap: string; emails: EmailOb /** A file of a kept message the user opened. Nothing else is kept: not an export, not a stray file. */ function recordBlob(ctx: StoreContext, params: { jmap: string; blobId: string; type: string | null; data: Uint8Array }): boolean { + if (!ctx.scope.attachments || params.data.byteLength > ctx.scope.maxAttachmentBytes) return false; const owner = ctx.db.get<{ received_at: number }>('SELECT received_at FROM email_part WHERE jmap = ? AND blob_id = ?', [params.jmap, params.blobId]); if (!owner) return false; putBlob(ctx.db, params.jmap, params.blobId, params.type, params.data, Number(owner.received_at)); diff --git a/lib/store/outbox/calls.ts b/lib/store/outbox/calls.ts index d34e38949..7c73e1a01 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; } 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/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', From 9b3bbc60706d0b0c5b75c341d86b63e79340375e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:18:41 +0100 Subject: [PATCH 19/27] fix: delete a store file whose key is gone, say that it happened, and stop waiting on an owner tab that doesn't answer --- lib/sign-out-cleanup.ts | 3 +- lib/store/__tests__/page-side.test.ts | 156 +++++++++++++++++++++++++- lib/store/client/api.ts | 39 ++++++- lib/store/client/keys.ts | 14 ++- lib/store/client/purge.ts | 16 ++- lib/store/client/store-client.ts | 51 ++++++++- lib/store/worker/host.ts | 11 +- lib/store/worker/protocol.ts | 1 + lib/store/worker/worker.ts | 11 +- 9 files changed, 283 insertions(+), 19 deletions(-) diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index f7ca42832..0effb91ac 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -38,9 +38,10 @@ const SIGNED_OUT_STORAGE_KEYS = [ 'bulwark-offline-accounts', // Which accounts' data waits for a new sign-in, and until when. 'bulwark-offline-evicted', + 'bulwark-store-opened', ]; -const LOCAL_STORE_KEYS = new Set(['bulwark-offline-accounts', 'bulwark-offline-evicted']); +const LOCAL_STORE_KEYS = new Set(['bulwark-offline-accounts', 'bulwark-offline-evicted', 'bulwark-store-opened']); const SIGNED_OUT_SIGNAL = 'bulwark:signed-out'; diff --git a/lib/store/__tests__/page-side.test.ts b/lib/store/__tests__/page-side.test.ts index ede57905f..fbd336b69 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,6 +26,7 @@ class FakeChannel { class FakeWorker { static all: FakeWorker[] = []; + static openResult: unknown; onmessage: ((event: MessageEvent) => void) | null = null; posted: Array<Record<string, unknown>> = []; terminated = false; @@ -33,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 } } 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; @@ -107,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'); @@ -165,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)}`); @@ -176,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' })) @@ -189,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({ @@ -214,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/client/api.ts b/lib/store/client/api.ts index 09cb18af2..83cb83655 100644 --- a/lib/store/client/api.ts +++ b/lib/store/client/api.ts @@ -7,7 +7,7 @@ import { DEFAULT_SCOPE, type StoreEvent, type StoreScope } from '../area'; import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport'; -import { forgetAccountKey, getAccountRecord, openAccountKey, type WrapLayer } from './keys'; +import { forgetAccountKey, getAccountRecord, knownStoreFiles, openAccountKey, type WrapLayer } from './keys'; import { StoreClient } from './store-client'; export interface StoreAccountInputs { @@ -25,6 +25,8 @@ export interface StorePageHooks { onEvent(event: StoreEvent): 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. */ @@ -49,6 +51,30 @@ 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>(); @@ -104,10 +130,12 @@ class PageStore implements StoreBackend { if (running) return running; const run = (async () => { const client = this.started(); - 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 }; if (inputs.scope) this.sentScope.set(account, JSON.stringify(inputs.scope)); - await client.openAccount({ + const opened = await client.openAccount({ account, file: record.file, keyHex, @@ -116,6 +144,10 @@ class PageStore implements StoreBackend { invertedKeywordSort: inputs.invertedKeywordSort, auth: inputs.auth, }); + 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)); @@ -183,6 +215,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 13b4af661..66e7c3b92 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,10 +262,16 @@ export class StoreClient { return this.isOwner; } - openAccount(params: OpenAccountParams): Promise<void> { + /** `created`: there was no database under this name. */ + openAccount(params: OpenAccountParams): Promise<{ created?: boolean } | undefined> { return this.direct({ kind: 'open', id: crypto.randomUUID(), params }); } + /** 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> { return this.direct({ kind: 'close-account', id: crypto.randomUUID(), account }); } @@ -266,6 +314,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/worker/host.ts b/lib/store/worker/host.ts index 9b56b594b..3a2a7036f 100644 --- a/lib/store/worker/host.ts +++ b/lib/store/worker/host.ts @@ -9,6 +9,11 @@ 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; @@ -44,7 +49,7 @@ export class StoreHost { constructor(private readonly options: StoreHostOptions) {} - async openAccount(params: OpenAccountParams): Promise<void> { + 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); @@ -52,9 +57,10 @@ export class StoreHost { existing.engine.setScope(params.scope); if (params.invertedKeywordSort !== null) existing.engine.setInvertedKeywordSort(params.invertedKeywordSort); void existing.engine.requestSync(); - return; + return { created: 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(); @@ -85,6 +91,7 @@ export class StoreHost { account.engine.setOnline(this.online); this.accounts.set(params.account, account); void account.engine.requestSync(); + return { created }; } setAuth(account: string, auth: string | null): void { diff --git a/lib/store/worker/protocol.ts b/lib/store/worker/protocol.ts index eefd34f96..5fefc7e0f 100644 --- a/lib/store/worker/protocol.ts +++ b/lib/store/worker/protocol.ts @@ -75,6 +75,7 @@ export type PageToWorker = | { kind: 'init'; clientId: string; moduleUrl: string } | { kind: 'open'; id: string; params: OpenAccountParams } | { kind: 'close-account'; id: string; account: 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 52e1c3688..a41ee1405 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; @@ -110,7 +110,14 @@ scope.addEventListener('message', async (event) => { break; case 'open': try { - await requireHost().openAccount(message.params); + reply(message.id, await requireHost().openAccount(message.params)); + } catch (error) { + replyError(message.id, error); + } + break; + case 'prune-files': + try { + for (const file of storeFilesToPrune(storage?.files() ?? [], message.keep)) storage?.remove(file); reply(message.id); } catch (error) { replyError(message.id, error); From 340ad27e32d105b7dd2a4d0ad9fea4bb10ce66d4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:18:42 +0100 Subject: [PATCH 20/27] fix: send waiting mail when a client connects after the first flush, and come back to stale sends and servers that were down --- components/offline/offline-host.tsx | 3 ++ lib/offline/__tests__/review-fixes.test.ts | 63 +++++++++++++++++++++- lib/offline/offline-mode.ts | 39 +++++++++++++- lib/offline/state.ts | 6 +++ 4 files changed, 108 insertions(+), 3 deletions(-) diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx index 3287a4d29..2eec8199b 100644 --- a/components/offline/offline-host.tsx +++ b/components/offline/offline-host.tsx @@ -53,6 +53,9 @@ export function OfflineHost() { case 'storage-failed': toast.warning(tConflictsRef.current('storage_failed'), { duration: 0 }); break; + case 'store-reset': + toast.warning(tConflictsRef.current('store_reset'), { duration: 0 }); + break; case 'store-unavailable': toast.warning(tConflictsRef.current('store_unavailable'), { duration: 0 }); break; diff --git a/lib/offline/__tests__/review-fixes.test.ts b/lib/offline/__tests__/review-fixes.test.ts index 93e28b9f2..af25802d3 100644 --- a/lib/offline/__tests__/review-fixes.test.ts +++ b/lib/offline/__tests__/review-fixes.test.ts @@ -61,6 +61,7 @@ function setOnline(online: boolean) { } let stop: () => void = () => {}; +const useRealClient = useAuthStore.getState().getClientForAccount; let store: InProcessStore; beforeEach(async () => { @@ -365,8 +366,10 @@ describe('what a sign-out would delete', () => { 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 }); - vi.spyOn(store, 'area').mockImplementation(() => new Promise(() => {})); + 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 () => { @@ -466,6 +469,64 @@ describe('a send the server couldn\'t take right now', () => { }); }); +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('looking up a send that may have gone', () => { it('waits until about 30 seconds after the last try', async () => { writeEnabled(['acc']); diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 96578ce2c..943aadb31 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -55,7 +55,7 @@ import { } from './queue'; import { clearHandoffs } from '@/lib/launch-handoff'; import { clearEvicted, EVICTED_KEEP_MS, forgetMailSnapshot, isEvicted, offlineScopeFor, isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, readEvicted, visibleAccounts, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state'; -import { flushQueue, LOOKUP_WAIT_MS, settleStaleSends } from './flush'; +import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from './flush'; import { LEGACY_DB_NAME, moveLegacyOutbox } from './migrate-legacy'; import { deleteShellCaches, tellServiceWorker } from './shell'; import { rememberLost } from './lost'; @@ -69,6 +69,7 @@ export type OfflineNotice = | { kind: 'conflict'; count: number } | { kind: 'storage-failed' } | { kind: 'store-unavailable' } + | { kind: 'store-reset' } | { kind: 'refused-delete' } | { kind: 'refused-other' } | { kind: 'forbidden' } @@ -169,6 +170,12 @@ const storeHooks: StorePageHooks = { await client.requestNetworkOnly([['Core/echo', {}, '0']]); return client.getAuthHeader(); }, + onStoreLost(account) { + // Whoever is here wasn't signed in to a dropped account: nothing to tell them. + if (isEvicted(account)) return; + useOfflineStore.setState((s) => ({ storeLost: [...new Set([...s.storeLost, account])] })); + notify({ kind: 'store-reset' }); + }, onEvent(event) { if (event.type === 'synced') { useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at } })); @@ -508,6 +515,7 @@ export async function refreshQueueState(): Promise<void> { // What an account's store can't tell right now is still there. const unread = useOfflineStore.getState().outbox.filter((row) => failed.includes(row.account)); useOfflineStore.setState({ outbox: [...unread, ...rows], pendingChanges: patches, conflicts }); + if (rows.some((row) => row.status === 'sending')) checkStaleSendsLater(); } catch { // The store is being deleted, or can't start. noteStoreUnavailable(); @@ -935,13 +943,22 @@ export function startOfflineMode(): () => void { void sweepAccountsGone(); // 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 = () => { + 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(); @@ -951,10 +968,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) void tellServiceWorker(true); @@ -989,6 +1013,17 @@ async function sweepAccountsGone(now = Date.now()): Promise<void> { } } +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); +} + /** How often the store catches up while the tab is open, on top of pushes. */ export const SYNC_INTERVAL_MS = 15 * 60 * 1000; diff --git a/lib/offline/state.ts b/lib/offline/state.ts index edada7809..31971e2db 100644 --- a/lib/offline/state.ts +++ b/lib/offline/state.ts @@ -32,6 +32,11 @@ interface OfflineState { downServers: string[]; /** The app started without the server, from the mail on this device. */ startedOffline: 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. */ @@ -57,6 +62,7 @@ export const useOfflineStore = create<OfflineState>()(() => ({ browserOffline: typeof navigator !== 'undefined' && navigator.onLine === false, downServers: [], startedOffline: false, + storeLost: [], outbox: [], pendingChanges: 0, conflicts: [], From 3dc2fc2c3c37a94fc23b6f801fd1ca9f67176023 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:25:05 +0100 Subject: [PATCH 21/27] fix: give up on a kept attachment only when the server says it won't come --- lib/store/__tests__/mail-answers.test.ts | 32 ++++++++++++++++++++++++ lib/store/mail/sync.ts | 6 ++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/store/__tests__/mail-answers.test.ts b/lib/store/__tests__/mail-answers.test.ts index a361d6f53..980b6ea12 100644 --- a/lib/store/__tests__/mail-answers.test.ts +++ b/lib/store/__tests__/mail-answers.test.ts @@ -471,6 +471,38 @@ 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' }] }); diff --git a/lib/store/mail/sync.ts b/lib/store/mail/sync.ts index ebe43fecc..f3919d1e4 100644 --- a/lib/store/mail/sync.ts +++ b/lib/store/mail/sync.ts @@ -376,10 +376,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)); @@ -388,6 +390,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']; From 024078c5975a27cc9c4544c98a7afe52fed9c67d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:25:06 +0100 Subject: [PATCH 22/27] fix: clear files held for nothing a week after a flush, leaving what other parts of the store still hold --- lib/offline/flush.ts | 18 ++++++++++++++++++ lib/store/__tests__/outbox.test.ts | 10 ++++++++++ lib/store/outbox/calls.ts | 8 ++++++++ 3 files changed, 36 insertions(+) diff --git a/lib/offline/flush.ts b/lib/offline/flush.ts index 0c21ef859..3aa05b411 100644 --- a/lib/offline/flush.ts +++ b/lib/offline/flush.ts @@ -176,6 +176,23 @@ export async function flushQueue(account: string, client: JMAPClient, deps: Flus await run(); } +/** 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(() => {}); +} + +/** 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(); @@ -320,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/store/__tests__/outbox.test.ts b/lib/store/__tests__/outbox.test.ts index 2c8d5dbc6..f27160d77 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'; @@ -492,6 +493,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/outbox/calls.ts b/lib/store/outbox/calls.ts index 7c73e1a01..33b1f7228 100644 --- a/lib/store/outbox/calls.ts +++ b/lib/store/outbox/calls.ts @@ -162,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; From ebec09212e990092cb0bb6c5e03ee1a004a0a941 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:25:07 +0100 Subject: [PATCH 23/27] fix: say plainly when a private window, old stranded mail or a silent server is the problem, and clean the domain of a legacy Message-ID --- components/email/email-composer.tsx | 4 + .../offline/__tests__/offline-host.test.tsx | 55 ++++++++++++ .../__tests__/offline-settings.test.tsx | 11 +++ components/offline/offline-host.tsx | 3 + components/offline/offline-settings.tsx | 6 +- lib/jmap/offline-hooks.ts | 7 ++ lib/offline/__tests__/migrate-legacy.test.ts | 16 ++++ lib/offline/__tests__/review-fixes.test.ts | 89 ++++++++++++++++++- lib/offline/migrate-legacy.ts | 17 +++- lib/offline/offline-mode.ts | 26 +++++- lib/offline/state.ts | 3 + lib/store/adapters/wasm.ts | 6 ++ .../__tests__/auth-store-keep-local.test.ts | 19 ++++ 13 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 components/offline/__tests__/offline-host.test.tsx diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 13418b806..26dfb8585 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"; @@ -522,6 +523,7 @@ export function EmailComposer({ 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); @@ -4069,6 +4071,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]) }); 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/offline-host.tsx b/components/offline/offline-host.tsx index 2eec8199b..0ddb51046 100644 --- a/components/offline/offline-host.tsx +++ b/components/offline/offline-host.tsx @@ -53,6 +53,9 @@ export function OfflineHost() { case 'storage-failed': toast.warning(tConflictsRef.current('storage_failed'), { duration: 0 }); break; + case 'legacy-stranded': + toast.warning(tConflictsRef.current('legacy_stranded'), { duration: 0 }); + break; case 'store-reset': toast.warning(tConflictsRef.current('store_reset'), { duration: 0 }); break; diff --git a/components/offline/offline-settings.tsx b/components/offline/offline-settings.tsx index 013e37ecb..d8c1a0439 100644 --- a/components/offline/offline-settings.tsx +++ b/components/offline/offline-settings.tsx @@ -30,6 +30,8 @@ export function OfflineSettings() { const syncing = useOfflineStore((s) => !!account && s.syncing.includes(account)); const lastSynced = useOfflineStore((s) => (account ? s.lastSyncedAt[account] : undefined)); const unsent = useOfflineStore((s) => s.outbox.filter((row) => row.account === account).length); + const blocked = useOfflineStore((s) => s.storageBlocked); + const tNotes = useTranslations('offlineConflicts'); const [confirmOff, setConfirmOff] = useState(false); if (IS_LITE) return null; @@ -42,7 +44,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> ) : !allowed ? ( <div className="py-3" data-testid="offline-forbidden"> diff --git a/lib/jmap/offline-hooks.ts b/lib/jmap/offline-hooks.ts index 753b4273d..fed004080 100644 --- a/lib/jmap/offline-hooks.ts +++ b/lib/jmap/offline-hooks.ts @@ -88,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/offline/__tests__/migrate-legacy.test.ts b/lib/offline/__tests__/migrate-legacy.test.ts index 37e3fa492..0639de69d 100644 --- a/lib/offline/__tests__/migrate-legacy.test.ts +++ b/lib/offline/__tests__/migrate-legacy.test.ts @@ -84,6 +84,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(); diff --git a/lib/offline/__tests__/review-fixes.test.ts b/lib/offline/__tests__/review-fixes.test.ts index af25802d3..7282d6d62 100644 --- a/lib/offline/__tests__/review-fixes.test.ts +++ b/lib/offline/__tests__/review-fixes.test.ts @@ -19,6 +19,7 @@ import { confirmLoss, enforcePolicy, offlineSessionFor, + offlineSupported, pluginsNotRunning, refreshQueueState, setOfflineNotifier, @@ -27,9 +28,11 @@ import { unsentCount, wipeEverything, } from '../offline-mode'; -import { flushQueue, LOOKUP_WAIT_MS, settleStaleSends, SENDING_STALE_MS } from '../flush'; +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 } from '../migrate-legacy'; +import { forgetLegacyMove, strandedLegacyMessages } from '../migrate-legacy'; import { consumeLost } from '../lost'; import { getOutboxHooks } from '../outbox-hooks'; import { shellFiles } from '../shell'; @@ -527,6 +530,88 @@ describe('waiting mail after a normal start', () => { }); }); +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', async () => { + writeEnabled([]); + 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); + } 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']); diff --git a/lib/offline/migrate-legacy.ts b/lib/offline/migrate-legacy.ts index d9a6c0253..12877bc98 100644 --- a/lib/offline/migrate-legacy.ts +++ b/lib/offline/migrate-legacy.ts @@ -144,7 +144,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, @@ -190,6 +191,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 943aadb31..82e35b41c 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -56,7 +56,7 @@ import { import { clearHandoffs } from '@/lib/launch-handoff'; import { clearEvicted, EVICTED_KEEP_MS, forgetMailSnapshot, isEvicted, offlineScopeFor, isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, readEvicted, visibleAccounts, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state'; import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from './flush'; -import { LEGACY_DB_NAME, moveLegacyOutbox } from './migrate-legacy'; +import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy'; import { deleteShellCaches, tellServiceWorker } from './shell'; import { rememberLost } from './lost'; @@ -70,6 +70,7 @@ export type OfflineNotice = | { kind: 'storage-failed' } | { kind: 'store-unavailable' } | { kind: 'store-reset' } + | { kind: 'legacy-stranded' } | { kind: 'refused-delete' } | { kind: 'refused-other' } | { kind: 'forbidden' } @@ -98,7 +99,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 (IS_LITE || !hasIndexedDb() || useOfflineStore.getState().storageBlocked) return false; try { return typeof crypto !== 'undefined' && !!crypto.subtle && getStore().supported(); } catch { @@ -878,6 +879,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(); @@ -941,6 +943,7 @@ export function startOfflineMode(): () => void { 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: @@ -1024,6 +1027,25 @@ function checkStaleSendsLater(): void { }, 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; diff --git a/lib/offline/state.ts b/lib/offline/state.ts index 31971e2db..cfff48f59 100644 --- a/lib/offline/state.ts +++ b/lib/offline/state.ts @@ -32,6 +32,8 @@ 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. @@ -63,6 +65,7 @@ export const useOfflineStore = create<OfflineState>()(() => ({ downServers: [], startedOffline: false, storeLost: [], + storageBlocked: false, outbox: [], pendingChanges: 0, conflicts: [], diff --git a/lib/store/adapters/wasm.ts b/lib/store/adapters/wasm.ts index 2870e7eae..d9ec6387d 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/stores/__tests__/auth-store-keep-local.test.ts b/stores/__tests__/auth-store-keep-local.test.ts index c1db3bae1..99ba77f90 100644 --- a/stores/__tests__/auth-store-keep-local.test.ts +++ b/stores/__tests__/auth-store-keep-local.test.ts @@ -192,6 +192,25 @@ describe('data waiting for a sign-in', () => { }); }); +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); From 9dd6e61d35b8410e6ef902878a4f6d5d2db60dbf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:25:08 +0100 Subject: [PATCH 24/27] test: read the store's raw files without an owner, lose a key for real, and lose only the submission of a send --- integration/tests/22-ent-offline.spec.ts | 100 ++++++++++++++++++++++- 1 file changed, 96 insertions(+), 4 deletions(-) diff --git a/integration/tests/22-ent-offline.spec.ts b/integration/tests/22-ent-offline.spec.ts index ff4dcd3cf..118302e51 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); + await expect(page.getByText("couldn't be read", { exact: false })).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); + await expect(page.getByText("couldn't be read", { exact: false })).toBeVisible(); + 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 }) => { From 7dc4c775cd3f20c5341aed63b56d3967f66a01f2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:45:03 +0100 Subject: [PATCH 25/27] fix: don't wipe a store just because this window can't open it, and don't send a stand-in when a file's upload is held again --- lib/offline/__tests__/review-fixes.test.ts | 20 ++++++++++++++++++-- lib/offline/offline-mode.ts | 11 ++++++++--- lib/store/__tests__/outbox.test.ts | 6 ++++-- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/lib/offline/__tests__/review-fixes.test.ts b/lib/offline/__tests__/review-fixes.test.ts index 7282d6d62..a5bbc6a0d 100644 --- a/lib/offline/__tests__/review-fixes.test.ts +++ b/lib/offline/__tests__/review-fixes.test.ts @@ -460,6 +460,19 @@ describe('a send the server couldn\'t take right now', () => { 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'); @@ -583,13 +596,16 @@ describe('a browser that can\'t run the store', () => { expect(await strandedLegacyMessages()).toBe(0); }); - it('turns the switch off in a window that refuses the private file system', async () => { - writeEnabled([]); + 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 }); diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 82e35b41c..b790335ac 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -39,7 +39,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 { @@ -99,7 +99,8 @@ 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() || useOfflineStore.getState().storageBlocked) return false; + if (IS_LITE || !hasIndexedDb()) return false; + if (useOfflineStore.getState().storageBlocked) return false; try { return typeof crypto !== 'undefined' && !!crypto.subtle && getStore().supported(); } catch { @@ -457,6 +458,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; @@ -814,7 +817,9 @@ 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(); diff --git a/lib/store/__tests__/outbox.test.ts b/lib/store/__tests__/outbox.test.ts index f27160d77..d820ab2d5 100644 --- a/lib/store/__tests__/outbox.test.ts +++ b/lib/store/__tests__/outbox.test.ts @@ -462,8 +462,10 @@ describe('sends', () => { it('doesn\'t call a try that never started an attempt', 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: 'waiting' }); + 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(); }); From 19f6bb8a071f828db12c3186983c997458a0a031 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:11:31 +0100 Subject: [PATCH 26/27] test: find the store reset notice once, not also in the screen reader region --- integration/tests/22-ent-offline.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/tests/22-ent-offline.spec.ts b/integration/tests/22-ent-offline.spec.ts index 118302e51..3a32f0af6 100644 --- a/integration/tests/22-ent-offline.spec.ts +++ b/integration/tests/22-ent-offline.spec.ts @@ -305,13 +305,13 @@ test('the files on the device hold no readable mail, and a store whose key went request.onblocked = () => resolve(); })); await page.goto(new URL('/', page.url()).href); - await expect(page.getByText("couldn't be read", { exact: false })).toBeVisible({ timeout: 60_000 }); + // 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); - await expect(page.getByText("couldn't be read", { exact: false })).toBeVisible(); const after = await rawStoreFiles(page, [file, newFile]); expect(after.found).toEqual([newFile]); await page.context().close(); From eb365938a202ffe437b983c15096991e9c4e9d42 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:26:00 +0100 Subject: [PATCH 27/27] test: line the merged branches' tests up with the store's wipe rules The token route now answers 503 when the provider can't be reached, in every build, and a refused renewal for a background account now removes it as evicted, which keeps its data unless the server said invalid_grant. Two assertions from the desktop and pop-out branches change for that. Also drops a duplicate import from the same merge. --- lib/__tests__/lite-scripts.test.ts | 1 - lib/desktop/__tests__/custom-upstream.test.ts | 4 ++-- stores/__tests__/auth-store-shared-session.test.ts | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/__tests__/lite-scripts.test.ts b/lib/__tests__/lite-scripts.test.ts index 2b7e49d95..9661323cb 100644 --- a/lib/__tests__/lite-scripts.test.ts +++ b/lib/__tests__/lite-scripts.test.ts @@ -60,7 +60,6 @@ import { stalwartRewriteIndex, stalwartZipEntryProblems, unexpectedEngineFiles, - LITE_REMOVED_PATHS, unexpectedApiStrings, withScriptHashCsp, } from '../../scripts/lite/lib.mjs'; diff --git a/lib/desktop/__tests__/custom-upstream.test.ts b/lib/desktop/__tests__/custom-upstream.test.ts index b5cd233be..6e3bd20a7 100644 --- a/lib/desktop/__tests__/custom-upstream.test.ts +++ b/lib/desktop/__tests__/custom-upstream.test.ts @@ -221,11 +221,11 @@ describe('in a server build', () => { expect(calls[0].url).toBe(`${CONFIGURED}/auth/token`); }); - it('still answers 500 on an unexpected failure', async () => { + 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(500); + expect(res.status).toBe(503); }); }); diff --git a/stores/__tests__/auth-store-shared-session.test.ts b/stores/__tests__/auth-store-shared-session.test.ts index c8bad00ec..8077e3631 100644 --- a/stores/__tests__/auth-store-shared-session.test.ts +++ b/stores/__tests__/auth-store-shared-session.test.ts @@ -335,7 +335,7 @@ describe('renewals in a main window', () => { useAuthStore.setState({ activeAccountId: 'bob' }); release(); await renewal; - expect(removeAccount).toHaveBeenCalledWith('alice'); + expect(removeAccount).toHaveBeenCalledWith('alice', 'evicted'); expect(logout).not.toHaveBeenCalled(); }); });