chore: merge the reviewed mail branch, pop-outs and desktop core into the offline drafts branch

The flush keeps one `outbox.dropOrphans` caller, the mail branch's daily
one with its 7 days, and drafts' own query says what's needed, so
`addHeldFileSource` and its test are gone. `unsentCount` counts local
drafts, and a draft a waiting send names counts once. The scope check in
`recordBlob` and the permanent-status rule moved to
`lib/store/mail/attachments.ts`, with tests.
This commit is contained in:
Linus Rath
2026-11-03 10:40:52 +01:00
197 changed files with 11357 additions and 422 deletions
+11 -2
View File
@@ -1,6 +1,8 @@
"use client";
import { Suspense, useEffect, useRef, useState } from "react";
import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag";
import { DESKTOP_CLIENT_ID_KEY, DESKTOP_REDIRECT_URI_KEY } from "@/lib/desktop/browser-sign-in";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
@@ -116,9 +118,12 @@ function OAuthCallbackInner() {
const prefix = getPathPrefix(params.locale as string);
// Lite on Stalwart registers one locale-free URI (lib/auth/lite-oauth.ts);
// the entry document brought us here from it.
const redirectUri = LITE_OAUTH_AVAILABLE
// The desktop app's browser sign-in redirected to its loopback listener
// (lib/desktop/browser-sign-in.ts), which this page's origin can't tell.
const desktopRedirectUri = IS_DESKTOP_BUILD ? sessionStorage.getItem(DESKTOP_REDIRECT_URI_KEY) : null;
const redirectUri = desktopRedirectUri ?? (LITE_OAUTH_AVAILABLE
? getLiteOAuthRedirectUri()
: `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
: `${window.location.origin}${prefix}/${params.locale}/auth/callback`);
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri, serverId)
.then((success) => {
@@ -128,6 +133,10 @@ function OAuthCallbackInner() {
sessionStorage.removeItem("oauth_server_url");
sessionStorage.removeItem("oauth_server_id");
sessionStorage.removeItem("oauth_add_account_mode");
if (IS_DESKTOP_BUILD) {
sessionStorage.removeItem(DESKTOP_REDIRECT_URI_KEY);
sessionStorage.removeItem(DESKTOP_CLIENT_ID_KEY);
}
let redirectTo = `${prefix}/${params.locale}`;
try {
const saved = sessionStorage.getItem('redirect_after_login');
+23 -10
View File
@@ -34,6 +34,9 @@ import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt
import { SessionTimeoutGuard } from "@/components/session/session-timeout-guard";
import { AccountTemplateSync } from "@/components/templates/account-template-sync";
import { FreeBusyAutoShare } from "@/components/calendar/free-busy-auto-share";
import { MainWindowOnly } from "@/components/popout/main-window-only";
import { PopoutHost } from "@/components/popout/popout-host";
import { WindowSyncHost } from "@/components/popout/window-sync-host";
import { IS_LITE } from "@/lib/lite";
import { setRequestLocale } from "next-intl/server";
import { locales } from "@/i18n/routing";
@@ -79,16 +82,22 @@ export default async function LocaleLayout({
<TourProvider>
<ProtocolLaunchHandlerProvider>
<ProInterfaceRedirect />
<ImpersonationReconciler />
<PushBindingsHost />
<MainWindowOnly>
<ImpersonationReconciler />
<PushBindingsHost />
<FreeBusyAutoShare />
{!IS_LITE && <PopoutHost />}
</MainWindowOnly>
{!IS_LITE && <WindowSyncHost />}
<SessionTimeoutGuard />
<AccountTemplateSync />
<FreeBusyAutoShare />
{children}
<PluginDialogHost />
<ExternalRecipientConfirmHost />
<PhishingReportHost />
<CommandPaletteHost />
<MainWindowOnly>
<CommandPaletteHost />
</MainWindowOnly>
<AccessibilityQuickPanelHost />
<PaneCycleHost />
<FocusRescueHost />
@@ -99,16 +108,20 @@ export default async function LocaleLayout({
<PluginConsentDialog />
<PluginOAuthCallbackListener />
<PluginAppEvents />
<PluginPageNavigator />
<PWAInstallPrompt />
<AppUpdatePrompt />
<MainWindowOnly>
<PluginPageNavigator />
<PWAInstallPrompt />
<AppUpdatePrompt />
</MainWindowOnly>
{/* Offline mode isn't in Lite (no service worker there). */}
{!IS_LITE && <OfflineHost />}
{!IS_LITE && <NotificationActionsHost />}
<DevicePolicyHost />
<PushNotificationPrompt />
{/* Lite is a static bundle: no server keeps an archive there. */}
{!IS_LITE && <AccountArchiveRestorePrompt />}
<MainWindowOnly>
<PushNotificationPrompt />
{/* Lite is a static bundle: no server keeps an archive there. */}
{!IS_LITE && <AccountArchiveRestorePrompt />}
</MainWindowOnly>
</ProtocolLaunchHandlerProvider>
</TourProvider>
</EmbeddedBridgeProvider>
+71
View File
@@ -1,6 +1,9 @@
"use client";
import { Suspense, useState, useEffect, useRef, useCallback, useId } from "react";
import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag";
import { forgetDesktopServer, getDesktopBridge } from "@/lib/desktop/bridge";
import { browserSignInAvailable, runDesktopBrowserSignIn } from "@/lib/desktop/browser-sign-in";
import { useRouter } from "@/i18n/navigation";
import { useParams, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -185,6 +188,10 @@ function VersionBadge() {
function LoginPageContent() {
const router = useRouter();
const t = useTranslations("login");
const tDesktop = useTranslations("desktop_sign_in");
// Only the desktop build sets these; a browser never shows them.
const [desktopNotice, setDesktopNotice] = useState<string | null>(null);
const [desktopWaiting, setDesktopWaiting] = useState(false);
const tSessionLimits = useTranslations("session_limits");
const tOffline = useTranslations("offline");
const tTheme = useTranslations("settings.appearance.theme");
@@ -985,6 +992,38 @@ function LoginPageContent() {
window.location.href = authUrl.toString();
};
/** The desktop build: the server's own sign-in page in the system browser. */
// Spelled out, not IS_DESKTOP_BUILD: only this form keeps the route out of other
// builds' bundles, which Lite's check on API strings relies on.
const handleDesktopBrowserSignIn = async () => {
if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1') {
const target = jmapEndpoint.trim();
if (!target) {
setDesktopNotice(tDesktop("server_needed"));
return;
}
setDesktopNotice(null);
setDesktopWaiting(true);
const prefix = getPathPrefix(params.locale as string);
const outcome = await runDesktopBrowserSignIn({
serverUrl: target,
callbackPath: `${prefix}/${params.locale}/auth/callback`,
slot: useAccountStore.getState().getNextCookieSlot(),
remember: persistProviderSession,
addAccount: isAddAccountMode,
});
if (outcome.kind === "callback") {
window.location.href = outcome.callbackUrl;
return;
}
setDesktopWaiting(false);
forgetDesktopServer(target, "failed");
if (outcome.kind === "unavailable") setDesktopNotice(tDesktop("browser_unavailable"));
else if (outcome.kind === "declined") setDesktopNotice(tDesktop("server_declined"));
else if (outcome.kind === "failed") setDesktopNotice(tDesktop("browser_failed"));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -992,9 +1031,21 @@ function LoginPageContent() {
// when the admin hasn't configured a server list.
const effectiveServerUrl = selectedServer?.url
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
// A LAN or plain http server gets a native prompt first, and the app can
// reach it from then on. Without a yes, nothing is sent there.
if (IS_DESKTOP_BUILD) {
setDesktopNotice(null);
const prepared = await getDesktopBridge()?.servers?.prepare(effectiveServerUrl);
if (prepared && !prepared.ok) {
setDesktopNotice(prepared.reason === "invalid" ? tDesktop("server_invalid") : tDesktop("server_declined"));
return;
}
}
if (signInWithToken) {
if (await loginWithToken(effectiveServerUrl, accessToken, persistSession)) {
router.push('/');
} else if (IS_DESKTOP_BUILD) {
forgetDesktopServer(effectiveServerUrl, 'failed');
}
return;
}
@@ -1046,6 +1097,8 @@ function LoginPageContent() {
} else if (isMobileHandoff) {
// Stale payload should never feed into a later retry's redirect.
mobileHandoffPayloadRef.current = null;
} else if (IS_DESKTOP_BUILD) {
forgetDesktopServer(effectiveServerUrl, 'failed');
}
};
@@ -1814,6 +1867,24 @@ function LoginPageContent() {
</>
)}
{process.env.NEXT_PUBLIC_BULWARK_DESKTOP === "1" && !hasServerList && browserSignInAvailable() && (
<Button
type="button"
variant="outline"
className="mt-3 w-full h-11 font-medium text-[0.9375rem] rounded-xl border-border/60 hover:bg-muted/50"
onClick={handleDesktopBrowserSignIn}
disabled={desktopWaiting || isLoading}
data-testid="desktop-browser-sign-in"
>
{desktopWaiting ? <Loader2 className="w-4 h-4 animate-spin me-2" /> : <LogIn className="w-4 h-4 me-2" />}
{desktopWaiting ? tDesktop("browser_waiting") : tDesktop("browser_button")}
</Button>
)}
{IS_DESKTOP_BUILD && desktopNotice && (
<p role="alert" data-testid="desktop-notice" className="mt-2 text-sm text-warning leading-relaxed">
{desktopNotice}
</p>
)}
{((serverOauthEnabled && oauthDiscoveryDone && !oauthMetadata) || liteOAuthFailed) && (
<div className="mt-2 p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
@@ -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 <PopoutComposeApp />;
}
@@ -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 <PopoutMessageApp id={decodeURIComponent(id)} />;
}
+1 -1
View File
@@ -509,7 +509,7 @@ export default function ProHome() {
return (
<EmbeddedContext.Provider value={true}>
{/* data-pro-shell: a page skeleton inside a pane draws its own frame, not the Pro one. */}
<div data-pro-shell="" className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<div data-pro-shell="" className="flex flex-col h-dvh bg-background overflow-hidden pt-[var(--app-top-inset)]">
{/* Once for the whole shell: email and folder tabs have no app strip, and split panes would show it twice. */}
<OrgAnnouncement />
<div className="flex flex-1 overflow-hidden">
+5
View File
@@ -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 <html> 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`}
>
<ServiceWorkerRegistration />
<WindowTitleBar />
{!themeColorConfigured && <ThemeColorSync />}
<FaviconBadge />
<ErrorReportingRoot />
+86
View File
@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { DEFAULT_OAUTH_SCOPES, withProviderDataScopes } from '@/lib/oauth/scopes';
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
import { resolveCustomUpstream } from '@/lib/oauth/custom-upstream';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
/** The client id on a server that won't register one: 0.16 accepts any id. */
const FALLBACK_CLIENT_ID = 'bulwark-desktop';
const REGISTERED_REDIRECT = 'http://127.0.0.1/bulwark-desktop/callback';
const REGISTER_TIMEOUT_MS = 5000;
function isLoopbackRedirect(value: unknown): boolean {
if (typeof value !== 'string') return false;
try {
const url = new URL(value);
return url.protocol === 'http:' && url.hostname === '127.0.0.1' && url.pathname === '/bulwark-desktop/callback';
} catch {
return false;
}
}
/**
* Registers the desktop app as a public client. Stalwart 1.0 needs this: its
* ids are stateless, so the same metadata gives the same id and nothing is
* stored. It accepts a loopback redirect and ignores the port when matching.
*/
async function registerClient(base: string, scope: string): Promise<string> {
try {
const response = await fetch(`${base}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'Bulwark',
redirect_uris: [REGISTERED_REDIRECT],
token_endpoint_auth_method: 'none',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
scope,
}),
redirect: 'error',
signal: AbortSignal.timeout(REGISTER_TIMEOUT_MS),
});
if (!response.ok) return FALLBACK_CLIENT_ID;
const body = (await response.json()) as { client_id?: unknown };
return typeof body.client_id === 'string' && body.client_id ? body.client_id : FALLBACK_CLIENT_ID;
} catch (error) {
logger.warn('Desktop OAuth: client registration failed', { error: error instanceof Error ? error.message : String(error) });
return FALLBACK_CLIENT_ID;
}
}
/**
* What the desktop app needs to send the user to a server's own sign-in page:
* the authorize endpoint and a client id. Only in the desktop build. Discovery
* runs here rather than in the page, for the same reason as on the login page:
* the documents often have no CORS headers.
*/
export async function POST(request: NextRequest) {
if (!IS_DESKTOP_BUILD) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
try {
const { serverUrl, redirectUri } = await request.json();
if (!isLoopbackRedirect(redirectUri)) {
return NextResponse.json({ error: 'invalid_redirect' }, { status: 400 });
}
const upstream = await resolveCustomUpstream(serverUrl, FALLBACK_CLIENT_ID);
if (!upstream) return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
const metadata = await discoverOAuth(upstream.serverUrl, { validateEndpoint: getDiscoveryValidator(upstream.serverUrl) });
if (!metadata) return NextResponse.json({ error: 'no_oauth' }, { status: 404 });
const scope = withProviderDataScopes(DEFAULT_OAUTH_SCOPES, metadata.scopes_supported);
return NextResponse.json({
authorization_endpoint: metadata.authorization_endpoint,
client_id: await registerClient(upstream.serverUrl, scope),
scope,
});
} catch (error) {
logger.error('Desktop OAuth discovery failed', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'discovery_failed' }, { status: 503 });
}
}
+1 -1
View File
@@ -184,7 +184,7 @@ export async function POST(request: NextRequest) {
// password. `trusted` only relaxes the public-address requirement.
// Wrong credentials tried from here count against this server's address
// upstream, so each client gets a failure budget (lib/auth/verify-budget.ts).
const clientIp = loginClientAddress(request);
const clientIp = loginClientAddress(request, upstreamUrl);
const refund = await reserveLoginAttempt(clientIp);
if (!refund) {
return NextResponse.json(
+1 -1
View File
@@ -80,7 +80,7 @@ export async function POST(request: NextRequest) {
// private network) - never the credential check.
// Wrong credentials tried from here count against this server's address
// upstream, so each client gets a failure budget (lib/auth/verify-budget.ts).
const clientIp = loginClientAddress(request);
const clientIp = loginClientAddress(request, upstreamUrl);
const refund = await reserveLoginAttempt(clientIp);
if (!refund) {
return NextResponse.json(
+48 -12
View File
@@ -41,6 +41,14 @@ import {
writeSignInStamp,
} from '@/lib/auth/session-age';
import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import {
clearCustomUpstream,
readCustomUpstream,
resolveCustomUpstream,
writeCustomUpstream,
type CustomUpstream,
} from '@/lib/oauth/custom-upstream';
function parseSlot(raw: string | null): number | null {
if (raw === null) return null;
@@ -57,13 +65,13 @@ function getSlot(request: NextRequest): number {
// not hold it up for long.
const REVOCATION_TIMEOUT_MS = 3000;
async function revokeRefreshToken(token: string, serverId: string | null, metadata: OAuthMetadata | null): Promise<void> {
async function revokeRefreshToken(token: string, serverId: string | null, metadata: OAuthMetadata | null, upstream: CustomUpstream | null): Promise<void> {
const revocationEndpoint = metadata?.revocation_endpoint;
if (!revocationEndpoint) return;
const params = buildOAuthParams({
token,
token_type_hint: 'refresh_token',
}, serverId, await defaultClientOptions(serverId).catch(() => ({ fallbackClientId: DEFAULT_CLIENT_ID })));
}, serverId, await defaultClientOptions(serverId, upstream).catch(() => ({ fallbackClientId: DEFAULT_CLIENT_ID })));
try {
// Never follow a redirect with the refresh token and client secret in
@@ -90,12 +98,12 @@ async function revokeRefreshToken(token: string, serverId: string | null, metada
* Stalwart, and for a logout page shared with other apps, which must keep
* working once the provider starts advertising its own.
*/
function endSessionUrlFor(metadata: OAuthMetadata | null, serverId: string | null, idToken: string | undefined): string | null {
function endSessionUrlFor(metadata: OAuthMetadata | null, serverId: string | null, idToken: string | undefined, upstream: CustomUpstream | null): string | null {
if (!isEndSessionEnabled()) return null;
let clientId: string;
let configuredEndpoint: string | undefined;
try {
({ clientId, endSessionUrl: configuredEndpoint } = getRequiredConfig(serverId));
({ clientId, endSessionUrl: configuredEndpoint } = upstream ? getRequiredConfig(serverId, { upstream }) : getRequiredConfig(serverId));
} catch {
return null;
}
@@ -146,7 +154,7 @@ export async function POST(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
try {
const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId, remember: bodyRemember } = await request.json();
const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId, remember: bodyRemember, custom_server_url: customServerUrl, client_id: bodyClientId } = await request.json();
// "Remember me" left unticked: the cookies end with the browser session.
// Clients from before the choice remembered every sign-in.
const remember = bodyRemember !== false;
@@ -158,7 +166,14 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
// The desktop app signs in to a server the user typed, with a client it
// registered there. Nothing configured here knows about either.
const customUpstream = IS_DESKTOP_BUILD && !serverId ? await resolveCustomUpstream(customServerUrl, bodyClientId) : null;
if (IS_DESKTOP_BUILD && !serverId && !customUpstream) {
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
}
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId, customUpstream ? { upstream: customUpstream } : undefined);
// A sign-in: the maximum session length counts from here, on the server
// and, from this answer, in the browser.
const signedInAt = Date.now();
@@ -187,6 +202,7 @@ export async function POST(request: NextRequest) {
} else if (cookieStore.get(serverCookieName)) {
cookieStore.delete(serverCookieName);
}
if (customUpstream) writeCustomUpstream(cookieStore, slot, customUpstream, remember);
return response;
} catch (error) {
@@ -260,13 +276,14 @@ export async function PUT(request: NextRequest) {
// Discovery or the token endpoint out of reach is an outage: the client
// waits it out. A 500 here would count toward signing the user out.
let tokenResponse: Response;
const upstream = IS_DESKTOP_BUILD ? await readCustomUpstream(cookieStore, slot) : null;
try {
const tokenEndpoint = await getTokenEndpoint(serverId, { fallbackClientId: DEFAULT_CLIENT_ID });
const tokenEndpoint = await getTokenEndpoint(serverId, upstream ? { upstream } : { fallbackClientId: DEFAULT_CLIENT_ID });
const params = buildOAuthParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
}, serverId, await defaultClientOptions(serverId));
}, serverId, await defaultClientOptions(serverId, upstream));
tokenResponse = await observeUpstream('oauth', () => fetch(tokenEndpoint, {
method: 'POST',
@@ -291,8 +308,12 @@ export async function PUT(request: NextRequest) {
cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
cookieStore.delete(accessTokenCookieName(slot));
clearCustomUpstream(cookieStore, slot);
clearIdToken(cookieStore, slot, request.nextUrl.basePath);
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
// Only invalid_grant says the login itself is over. invalid_client, or
// a provider refusing an early refresh (#552), says nothing about it,
// and the client keeps the account's local data for those.
return NextResponse.json({ error: oauthErrorCode(errorText) === 'invalid_grant' ? 'invalid_grant' : 'Refresh failed' }, { status: 401 });
}
return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 });
}
@@ -332,10 +353,23 @@ export async function PUT(request: NextRequest) {
});
} catch (error) {
logger.error('Token refresh error', { error: error instanceof Error ? error.message : 'Unknown error' });
// On the desktop this route runs on the user's machine, so an unreachable
// mail server lands here. The client signs out after repeated 500s; a 503
// tells it to keep the session and try again.
if (IS_DESKTOP_BUILD) return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
function oauthErrorCode(body: string): string | null {
try {
const code = (JSON.parse(body) as { error?: unknown }).error;
return typeof code === 'string' ? code : null;
} catch {
return null;
}
}
/**
* Revoke a slot's refresh token and clear its token cookies. With
* `endSession`, also returns the URL that ends the provider's session, or null
@@ -351,19 +385,20 @@ async function signOutSlot(
const refreshToken = cookieStore.get(cookieName)?.value;
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
const idToken = cookieStore.get(idTokenCookieName(slot))?.value;
const upstream = IS_DESKTOP_BUILD ? await readCustomUpstream(cookieStore, slot) : null;
// With revocation off (#1388) the token is only dropped from the browser.
const tokenToRevoke = refreshToken && isRevokeOnSignOutEnabled() ? refreshToken : undefined;
let endSessionUrl: string | null = null;
if (tokenToRevoke || endSession) {
const metadata = await getMetadata(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }).catch((err) => {
const metadata = await getMetadata(serverId, upstream ? { upstream } : { fallbackClientId: DEFAULT_CLIENT_ID }).catch((err) => {
logger.warn('Failed to discover OAuth metadata during logout', {
error: err instanceof Error ? err.message : 'Unknown error',
});
return null;
});
if (tokenToRevoke) await revokeRefreshToken(tokenToRevoke, serverId, metadata);
if (endSession) endSessionUrl = endSessionUrlFor(metadata, serverId, idToken);
if (tokenToRevoke) await revokeRefreshToken(tokenToRevoke, serverId, metadata, upstream);
if (endSession) endSessionUrl = endSessionUrlFor(metadata, serverId, idToken, upstream);
}
// Cleared whether or not revocation worked: the browser must not be able
@@ -374,6 +409,7 @@ async function signOutSlot(
if (cookieStore.get(name)) cookieStore.delete(name);
}
clearSignInStamp(cookieStore, slot);
if (IS_DESKTOP_BUILD) clearCustomUpstream(cookieStore, slot);
if (idToken !== undefined) clearIdToken(cookieStore, slot, basePath);
return endSessionUrl;
}
+16 -2
View File
@@ -16,6 +16,10 @@ import { loginClientAddress } from '@/lib/auth/client-address';
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
import { writeSignInStamp } from '@/lib/auth/session-age';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { isDesktopTrustedServer } from '@/lib/desktop/trusted-servers';
import { stalwartDefaultClientId } from '@/lib/auth/stalwart-password-login';
import { writeCustomUpstream } from '@/lib/oauth/custom-upstream';
/**
* Exchange a password + (optional) TOTP code for OAuth tokens.
@@ -113,7 +117,12 @@ async function attemptLogin(
logger.info('TOTP login succeeded');
void recordLogin(username, base);
return await storeAndRespond(result.tokens, slot, serverId, rememberMe);
// The desktop app's servers are in no configured list, so the refresh has to
// be told where this token belongs and which client it was issued to.
const upstream = IS_DESKTOP_BUILD && !serverId
? { serverUrl: base, clientId: await stalwartDefaultClientId(base, clientId, upstreamTrusted) }
: null;
return await storeAndRespond(result.tokens, slot, serverId, rememberMe, upstream);
}
function trimUrl(url: string): string {
@@ -164,6 +173,9 @@ export async function POST(request: NextRequest) {
resolvedServerId = matchedEntry.id;
} else if (configuredServerUrl && !customRequested) {
upstreamUrl = configuredServerUrl;
// Spelled out as in lib/admin/jmap-servers.ts, so other builds don't hold it.
} else if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && isDesktopTrustedServer(serverUrl)) {
upstreamUrl = serverUrl;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
logger.warn('TOTP login: rejected non-public server URL');
@@ -193,7 +205,7 @@ export async function POST(request: NextRequest) {
slot,
resolvedServerId,
upstreamTrusted,
loginClientAddress(request),
loginClientAddress(request, upstreamUrl),
// Clients from before the flag remembered every sign-in.
rememberMe !== false,
);
@@ -210,6 +222,7 @@ async function storeAndRespond(
slot: number,
serverId: string | null,
rememberMe: boolean,
upstream: { serverUrl: string; clientId: string } | null,
): Promise<NextResponse> {
const cookieStore = await cookies();
// A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
@@ -227,6 +240,7 @@ async function storeAndRespond(
} else {
cookieStore.delete(serverCookieName);
}
if (upstream && tokens.refresh_token) writeCustomUpstream(cookieStore, slot, upstream, rememberMe);
return NextResponse.json({
access_token: tokens.access_token,
+2 -2
View File
@@ -5,7 +5,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getClientIP } from '@/lib/admin/session';
import { loginClientAddress } from '@/lib/auth/client-address';
import { reserveVerifyProbe, type BudgetRefund } from '@/lib/auth/verify-budget';
import { isStoreUnavailable } from '@/lib/auth/store-unavailable';
@@ -83,7 +83,7 @@ export async function POST(request: NextRequest) {
// Wrong passwords tried from here count against this server's address
// upstream; over budget the browser probes the JMAP server itself.
const clientIp = getClientIP(request);
const clientIp = loginClientAddress(request, upstreamUrl);
let refund: BudgetRefund | null;
try {
refund = await reserveVerifyProbe(clientIp);
+76
View File
@@ -0,0 +1,76 @@
/*
* The desktop app is an app, not a page. Everything here hangs on
* html[data-desktop], which the boot script sets when the preload bridge is
* there (lib/desktop/boot.ts), so a browser never matches any of it.
*
* UI chrome is listed one by one, instead of switching selection off for the
* whole page and listing what may stay on: a message, a contact's details or
* an event's notes must not stop being selectable because nobody thought of
* them. An HTML message is in its own frame; a plain-text one is in the page,
* and the rule at the end keeps it selectable wherever it sits.
*/
html[data-desktop],
html[data-desktop] body {
overscroll-behavior: none;
}
html[data-desktop] :is(
button,
summary,
label,
nav,
[role="button"],
[role="navigation"],
[role="toolbar"],
[role="menu"],
[role="menubar"],
[role="menuitem"],
[role="menuitemcheckbox"],
[role="menuitemradio"],
[role="tab"],
[role="tablist"],
[role="listbox"],
[role="option"],
[role="tree"],
[role="treeitem"],
[role="row"],
[data-composer-header],
[data-mv-compose-titlebar],
[data-window-title-bar]
) {
-webkit-user-select: none;
user-select: none;
cursor: default;
}
/* A field inside a label or a row is still a field. */
html[data-desktop] :is(input, textarea, select, [contenteditable="true"], [contenteditable="plaintext-only"]) {
-webkit-user-select: text;
user-select: text;
}
html[data-desktop] :is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="button"], [type="submit"]), textarea, [contenteditable="true"], [contenteditable="plaintext-only"]) {
cursor: text;
}
/* No dragging an icon or a link by accident. What the app means to drag says so. */
html[data-desktop] :is(img, a, svg) {
-webkit-user-drag: none;
}
html[data-desktop] [draggable="true"] {
-webkit-user-drag: element;
}
/* A plain-text message renders in the page, also inside rows and buttons. */
html[data-desktop] [data-plugin-source="email-body"],
html[data-desktop] [data-plugin-source="email-body"] * {
-webkit-user-select: text;
user-select: text;
cursor: text;
}
html[data-desktop] [data-plugin-source="email-body"] a {
cursor: pointer;
}
+19
View File
@@ -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);
+5 -1
View File
@@ -35,7 +35,8 @@ type WebAppShareTarget = {
};
};
type ExtendedManifest = MetadataRoute.Manifest & {
type ExtendedManifest = Omit<MetadataRoute.Manifest, "display_override"> & {
display_override?: Array<"window-controls-overlay" | "standalone" | "minimal-ui" | "browser" | "fullscreen">;
protocol_handlers?: WebAppProtocolHandler[];
file_handlers?: WebAppFileHandler[];
share_target?: WebAppShareTarget;
@@ -124,6 +125,9 @@ export default async function manifest(): Promise<ExtendedManifest> {
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,
+2 -2
View File
@@ -263,7 +263,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
const backHref = location.detail ? adminPath(location.page) : '/admin';
return (
<AdminShellContext.Provider value={contextValue}>
<div className="flex h-dvh flex-col bg-background pt-[env(safe-area-inset-top)]">
<div className="flex h-dvh flex-col bg-background pt-[var(--app-top-inset)]">
<div className="flex h-14 shrink-0 items-center gap-1 border-b border-border bg-background px-2">
{showIndex ? (
<a
@@ -361,7 +361,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
return (
<AdminShellContext.Provider value={contextValue}>
<div className="flex h-dvh bg-background pt-[env(safe-area-inset-top)]">
<div className="flex h-dvh bg-background pt-[var(--app-top-inset)]">
{/* The same rail as mail and settings, with Admin as the current app. */}
<div className="flex w-14 shrink-0 flex-col bg-secondary" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail collapsed adminActive />
+1 -1
View File
@@ -171,7 +171,7 @@ export function AdminSkeleton({ label, page, backLabel, searchLabel, labels, cla
<SkeletonRegion
surface="admin"
label={label}
className={cn("h-dvh bg-background pt-[env(safe-area-inset-top)]", className)}
className={cn("h-dvh bg-background pt-[var(--app-top-inset)]", className)}
>
{/* Desktop */}
<div className="hidden lg:flex h-full">
+1 -1
View File
@@ -109,7 +109,7 @@ export function ProFrameSkeleton({
<SkeletonRegion
surface="pro"
label={label}
className={cn("flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]", className)}
className={cn("flex flex-col h-dvh bg-background overflow-hidden pt-[var(--app-top-inset)]", className)}
>
<div data-pro-shell="" className="flex flex-1 overflow-hidden">
<RailSkeleton search className="flex" />
+1 -1
View File
@@ -294,7 +294,7 @@ export function AppShellSkeleton({
<SkeletonRegion
surface={surface}
label={label}
className="flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] h-dvh bg-background lg:mv:bg-[var(--mv-canvas)] boot-out:hidden boot-pro-frame:hidden"
className="flex flex-col overflow-hidden pt-[var(--app-top-inset)] h-dvh bg-background lg:mv:bg-[var(--mv-canvas)] boot-out:hidden boot-pro-frame:hidden"
>
<MvTopBarSkeleton search={mvSearch} menu={mvMenu} className="hidden lg:mv:flex" />
<div className={cn("flex flex-1 min-h-0 overflow-hidden", rowClassName)}>
+3 -3
View File
@@ -2163,7 +2163,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
);
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")}>
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
<AppTopBannerSlot />
{refreshIndicator}
@@ -2221,7 +2221,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
"border-e border-border bg-secondary flex flex-col overflow-hidden flex-shrink-0",
!isResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 start-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
"absolute inset-y-0 start-0 z-50 w-72 pt-[var(--app-top-inset)]",
"transform transition-transform duration-300 ease-in-out",
!narrowSidebarOpen && "-translate-x-full rtl:translate-x-full"
)
@@ -2556,7 +2556,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
role="dialog"
aria-modal="true"
aria-label={editTask ? t("tasks.edit") : t("tasks.create")}
className={isPaneScoped ? "absolute inset-0 z-40 flex flex-col bg-background" : "fixed inset-0 z-50 flex flex-col bg-background"}
className={isPaneScoped ? "absolute inset-0 z-40 flex flex-col bg-background" : "fixed inset-0 z-50 flex flex-col bg-background pt-[var(--titlebar-inset)]"}
>
<TaskModal
key={editTask?.id ?? 'new-task'}
+1 -1
View File
@@ -288,7 +288,7 @@ export function EventModal({
const isPaneScoped = useIsPaneScoped();
const mobileRootClass = isPaneScoped
? "absolute inset-0 z-40 flex flex-col bg-background"
: "fixed inset-0 z-50 flex flex-col bg-background";
: "fixed inset-0 z-50 flex flex-col bg-background pt-[var(--titlebar-inset)]";
const isEdit = !!event;
const formatEventDate = useFormatEventDate();
const [mode, setMode] = useState<"view" | "edit">(isEdit && !startInEditMode ? "view" : "edit");
@@ -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",
)}
>
+2 -2
View File
@@ -1209,7 +1209,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
};
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")}>
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
<AppTopBannerSlot />
{refreshIndicator}
@@ -1265,7 +1265,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
"border-e border-border flex flex-col flex-shrink-0 bg-background",
!isSidebarResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 start-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
"absolute inset-y-0 start-0 z-50 w-72 pt-[var(--app-top-inset)]",
"transform transition-[transform,visibility] duration-300 ease-in-out",
!narrowSidebarOpen && "invisible -translate-x-full rtl:translate-x-full"
)
@@ -0,0 +1,364 @@
import React from 'react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextIntlClientProvider } from 'next-intl';
import { EmailComposer, type ComposerDraftData, type ComposerPopOut } from '../email-composer';
import { JMAPClient } from '@/lib/jmap/client';
import { useAuthStore } from '@/stores/auth-store';
import { useIdentityStore } from '@/stores/identity-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useToastStore } from '@/stores/toast-store';
import en from '@/locales/en/common.json';
import { emailHooks } from '@/lib/plugin-hooks';
// Moving a draft to its own window, and a composer that is all its window
// shows. Real composer, stores and JMAP draft payloads over a synthetic server.
vi.unmock('next-intl');
vi.mock('@/components/email/rich-text-editor', () => ({ RichTextEditor: () => null }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
type Props = React.ComponentProps<typeof EmailComposer>;
type Draft = { subject: string };
const original = { auth: useAuthStore.getState(), identity: useIdentityStore.getState(), settings: useSettingsStore.getState() };
let client: JMAPClient;
let creations: Draft[];
let rejectSave: boolean;
let deferSave: boolean;
let finishSave: (() => void) | undefined;
beforeEach(async () => {
creations = []; rejectSave = false; deferSave = false; finishSave = undefined;
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
capabilities: { 'urn:ietf:params:jmap:core': {} },
accounts: { owner: { name: 'me@example.test', isPersonal: true, accountCapabilities: {} } },
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'owner' },
apiUrl: 'https://synthetic.example.test/api', uploadUrl: 'https://synthetic.example.test/upload/{accountId}',
downloadUrl: 'https://synthetic.example.test/download/{accountId}/{blobId}/{name}', eventSourceUrl: '',
}))));
client = new JMAPClient('https://synthetic.example.test', 'me@example.test', 'synthetic-password');
await client.connect();
vi.spyOn(client, 'ping').mockResolvedValue(undefined);
vi.mocked(fetch).mockImplementation(async (_url, init) => {
const { methodCalls } = JSON.parse(String(init?.body)) as {
methodCalls: Array<[string, { create?: Record<string, Draft>; 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<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, rejectSave
? { notCreated: { draft: { type: 'serverFail', description: 'Synthetic refusal' } } }
: { created }, id]);
} else if (method === 'Email/get') methodResponses.push([method, { list: (args.ids ?? []).map((draftId) => ({ id: draftId, attachments: [] })) }, id]);
else if (method === 'Email/set') methodResponses.push([method, { destroyed: args.destroy ?? [] }, id]);
else methodResponses.push([method, {}, id]);
}
return new Response(JSON.stringify({ methodResponses }));
});
useAuthStore.setState({ client, activeAccountId: 'login', isAuthenticated: true, isLoading: false,
getClientForAccount: (id) => id === 'login' ? client : undefined,
getAllConnectedClients: () => new Map([['login', client]]),
});
useIdentityStore.setState({ identities: [{ id: 'me', email: 'me@example.test', name: 'Me', mayDelete: false } as never] });
useSettingsStore.setState({ defaultSendDelay: 0 } as never);
useToastStore.getState().clearToasts();
});
afterEach(() => {
cleanup();
client.disconnect();
useAuthStore.setState(original.auth);
useIdentityStore.setState(original.identity);
useSettingsStore.setState(original.settings);
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function mount(props: Partial<Props>) {
return render(
<NextIntlClientProvider locale="en" messages={en} timeZone="UTC">
<EmailComposer mode="compose" onClose={vi.fn()} {...props} />
</NextIntlClientProvider>,
);
}
const subjectField = () => screen.getByPlaceholderText(en.email_composer.subject_placeholder);
function target(shown = true): ComposerPopOut & { handed: ComposerDraftData[]; cancelled: number } {
const handed: ComposerDraftData[] = [];
const window = {
handed,
cancelled: 0,
handOver: async (data: ComposerDraftData) => { handed.push(data); return shown; },
cancel: () => { window.cancelled += 1; },
};
return window;
}
const DRAFT: ComposerDraftData = { to: '', cc: '', bcc: '', subject: 'Old', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: null };
describe('"Open in new window" on a composer', () => {
it('is not there without the host offering it: browsers stay as they are', () => {
mount({});
expect(screen.queryByTestId('composer-open-in-window')).toBeNull();
});
it('opens the window inside the click, before any save', async () => {
const popOut = vi.fn(() => target());
mount({ popOut });
fireEvent.click(screen.getByTestId('composer-open-in-window'));
// Synchronously: a browser only opens a window for a user action.
expect(popOut).toHaveBeenCalledTimes(1);
await waitFor(() => expect(popOut.mock.results[0].value.handed).toHaveLength(1));
});
it('saves the text, hands over the same draft id, and closes here', async () => {
const window = target();
const onClose = vi.fn();
const onSaveState = vi.fn();
mount({ popOut: () => window, onClose, onSaveState });
fireEvent.change(subjectField(), { target: { value: 'Moves along' } });
fireEvent.click(screen.getByTestId('composer-open-in-window'));
await waitFor(() => expect(window.handed).toHaveLength(1));
const data = window.handed[0];
expect(data.subject).toBe('Moves along');
expect(creations.map((draft) => draft.subject)).toEqual(['Moves along']);
expect(data.draftId).toBe('saved-1');
expect(data.unsaved).toBe(false);
expect(onClose).toHaveBeenCalledTimes(1);
// Not left behind in the host's "continue draft" slot.
cleanup();
expect(onSaveState).not.toHaveBeenCalled();
});
it('keeps a draft that is already saved under its id, without saving again', async () => {
const window = target();
mount({
popOut: () => window,
initialData: { to: '', cc: '', bcc: '', subject: 'Old', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: 'existing' },
});
fireEvent.click(screen.getByTestId('composer-open-in-window'));
await waitFor(() => expect(window.handed).toHaveLength(1));
expect(window.handed[0].draftId).toBe('existing');
expect(creations).toEqual([]);
});
it('keeps the composer and calls it off when the other window never shows it', async () => {
const window = target(false);
const onClose = vi.fn();
mount({ popOut: () => window, onClose });
fireEvent.change(subjectField(), { target: { value: 'Still here' } });
fireEvent.click(screen.getByTestId('composer-open-in-window'));
await waitFor(() => expect(window.cancelled).toBe(1));
expect(onClose).not.toHaveBeenCalled();
expect((subjectField() as HTMLInputElement).value).toBe('Still here');
expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain(en.pwaWindows.window_not_ready);
});
it('does not close here before the other window shows it, also after a failed save', async () => {
rejectSave = true;
let show: (shown: boolean) => void = () => {};
const handed: ComposerDraftData[] = [];
const onClose = vi.fn();
mount({
popOut: () => ({ cancel: vi.fn(), handOver: (data) => { handed.push(data); return new Promise((resolve) => { show = resolve; }); } }),
onClose,
});
fireEvent.change(subjectField(), { target: { value: 'Refused' } });
fireEvent.click(screen.getByTestId('composer-open-in-window'));
await waitFor(() => expect(handed).toHaveLength(1));
expect(handed[0].unsaved).toBe(true);
expect(onClose).not.toHaveBeenCalled();
show(true);
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
});
it('takes the send options along', async () => {
const window = target();
mount({ popOut: () => window, initialData: { ...DRAFT, requestReadReceipt: true, requestDsn: true, requireTls: true } });
fireEvent.click(screen.getByTestId('composer-open-in-window'));
await waitFor(() => expect(window.handed).toHaveLength(1));
expect(window.handed[0]).toMatchObject({ requestReadReceipt: true, requestDsn: true, requireTls: true });
});
it('stays open and says why when the browser blocks the window', async () => {
const onClose = vi.fn();
mount({ popOut: () => null, onClose });
fireEvent.change(subjectField(), { target: { value: 'Stays' } });
fireEvent.click(screen.getByTestId('composer-open-in-window'));
await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain(en.pwaWindows.popup_blocked));
expect(onClose).not.toHaveBeenCalled();
expect((subjectField() as HTMLInputElement).value).toBe('Stays');
});
});
describe('a composer that is all its window shows', () => {
function unload() {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return event;
}
it('asks before the window closes with unsaved text, and saves meanwhile', async () => {
const onUnloadState = vi.fn();
mount({ ownWindow: { onUnloadState } });
fireEvent.change(subjectField(), { target: { value: 'Not saved yet' } });
const event = unload();
expect(event.defaultPrevented).toBe(true);
expect(onUnloadState.mock.calls[0][0]).toMatchObject({ subject: 'Not saved yet', unsaved: true });
await waitFor(() => expect(creations.map((draft) => draft.subject)).toEqual(['Not saved yet']));
});
it('hands unsaved text to another window as it closes, with no prompt and no save of its own', () => {
const rescueDraft = vi.fn(() => true);
mount({ ownWindow: { rescueDraft } });
fireEvent.change(subjectField(), { target: { value: 'Rescued' } });
expect(unload().defaultPrevented).toBe(false);
expect(rescueDraft).toHaveBeenCalledTimes(1);
expect(rescueDraft.mock.calls[0]).toEqual([expect.objectContaining({ subject: 'Rescued', draftId: undefined })]);
expect(creations).toEqual([]);
});
it('replaces the version it holds when another window saves it', () => {
const rescueDraft = vi.fn(() => true);
mount({
ownWindow: { rescueDraft },
initialData: { to: '', cc: '', bcc: '', subject: 'Old', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: 'saved-before' },
});
fireEvent.change(subjectField(), { target: { value: 'New' } });
unload();
expect(rescueDraft.mock.calls[0]).toEqual([expect.objectContaining({ subject: 'New', draftId: 'saved-before' })]);
});
it('asks and saves itself when no other window can take it', async () => {
const rescueDraft = vi.fn(() => false);
mount({ ownWindow: { rescueDraft } });
fireEvent.change(subjectField(), { target: { value: 'Nobody home' } });
expect(unload().defaultPrevented).toBe(true);
await waitFor(() => expect(creations.map((draft) => draft.subject)).toEqual(['Nobody home']));
});
it('does not hand it over after a failed save: that window would likely fail too, with nobody looking', async () => {
rejectSave = true;
const rescueDraft = vi.fn(() => true);
mount({ ownWindow: { rescueDraft } });
fireEvent.change(subjectField(), { target: { value: 'Server down' } });
await waitFor(() => expect(creations).toHaveLength(1), { timeout: 4000 });
await waitFor(() => expect(unload().defaultPrevented).toBe(true));
expect(rescueDraft).not.toHaveBeenCalled();
});
it('does not hand it over while the browser is offline', () => {
const onLine = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
const rescueDraft = vi.fn(() => true);
mount({ ownWindow: { rescueDraft } });
fireEvent.change(subjectField(), { target: { value: 'Wi-Fi gone' } });
expect(unload().defaultPrevented).toBe(true);
expect(rescueDraft).not.toHaveBeenCalled();
onLine.mockRestore();
});
it('does not hand it over with a save running: two saves would make two drafts', async () => {
deferSave = true;
const rescueDraft = vi.fn(() => true);
mount({ ownWindow: { rescueDraft } });
fireEvent.change(subjectField(), { target: { value: 'Saving' } });
await waitFor(() => expect(creations).toHaveLength(1), { timeout: 4000 });
expect(unload().defaultPrevented).toBe(true);
expect(rescueDraft).not.toHaveBeenCalled();
finishSave?.();
});
it('asks when the window closes during a send', async () => {
let finish: () => void = () => {};
const onSend = vi.fn(() => new Promise<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);
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: '<p>Hello</p>', 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);
});
});
@@ -10,6 +10,7 @@ import { useSettingsStore } from '@/stores/settings-store';
import { useToastStore } from '@/stores/toast-store';
import { ToastContainer } from '@/components/ui/toast';
import en from '@/locales/en/common.json';
import { setDownloadGuard } from '@/lib/download-guard';
// Keep the actual viewer handler, translations, JMAP routing/auth/retries and
// JSZip. Stub unrelated viewer hosts, as in the other viewer component tests.
@@ -312,3 +313,147 @@ describe('Download all attachments', () => {
await p.assertZip(['report.txt', 'data.bin'], [alpha, beta]);
});
});
describe('Save all attachments to a folder', () => {
/** A folder as the picker hands it out; `failOn` makes that file's write fail. */
function fakeFolder(failOn?: string) {
const files = new Map<string, Blob | Uint8Array>();
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<unknown>) {
const picker = vi.fn(result);
(window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker = picker;
return picker;
}
const saveButton = () => screen.getByTestId('attachments-save-to-folder');
afterEach(() => {
delete (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker;
setDownloadGuard(null);
});
it('is not offered where the browser cannot ask for a folder, and the zip stays', async () => {
await openViewer();
expect(screen.queryByTestId('attachments-save-to-folder')).toBeNull();
expect(screen.getAllByTitle('Download all')[0]).toBeVisible();
});
it('keeps one row in a narrow pane: with two buttons, just the icons there, and always a name', async () => {
stubPicker(async () => fakeFolder());
await openViewer();
const save = saveButton();
expect(save.getAttribute('aria-label')).toBe(en.pwaWindows.save_all_to_folder);
expect(save.querySelector('span')?.className).toContain('hidden @xl:inline');
const download = screen.getAllByTitle('Download all')[0];
expect(download.getAttribute('aria-label')).toBe('Download all');
expect(download.querySelector('span')?.className).toContain('hidden @xl:inline');
expect(screen.getAllByTestId('reader-attachments')[0].className).toContain('@container');
});
it('shows the zip button as it was where there is only that one', async () => {
await openViewer();
const download = screen.getAllByTitle('Download all')[0];
expect(download.querySelector('span')?.className ?? '').not.toContain('hidden');
expect(download.textContent).toContain('Download all');
});
it('writes each attachment to the folder, names the folder, and starts no zip', async () => {
const folder = fakeFolder();
const picker = stubPicker(async () => folder);
const p = await openViewer();
p.success();
fireEvent.click(saveButton());
expect(picker).toHaveBeenCalledWith(expect.objectContaining({ mode: 'readwrite' }));
await waitFor(() => expect(useToastStore.getState().toasts).toEqual([
expect.objectContaining({ type: 'success', title: 'Saved 2 attachments to Downloads.' }),
]));
expect([...folder.files.keys()]).toEqual(['report.txt', 'data.bin']);
p.noDownload();
});
it('asks the device policy before the picker: no save-all when downloads are blocked', async () => {
const picker = stubPicker(async () => fakeFolder());
const p = await openViewer();
setDownloadGuard(() => false);
fireEvent.click(saveButton());
await act(async () => {});
expect(picker).not.toHaveBeenCalled();
expect(p.fetchMock).not.toHaveBeenCalled();
});
it('asks again before each file: a policy turning on stops the rest and says how many went', async () => {
const folder = fakeFolder();
stubPicker(async () => folder);
const p = await openViewer();
p.success();
let asked = 0;
// The click, then the first file's two checks. Off from the second file on.
setDownloadGuard(() => ++asked <= 3);
fireEvent.click(saveButton());
await waitFor(() => expect(useToastStore.getState().toasts).toEqual([
expect.objectContaining({ type: 'warning', title: 'Saved 1 of 2 attachments to Downloads.' }),
]));
expect([...folder.files.keys()]).toEqual(['report.txt']);
});
it('does nothing when the picker is closed', async () => {
stubPicker(async () => { throw Object.assign(new Error('closed'), { name: 'AbortError' }); });
const p = await openViewer();
fireEvent.click(saveButton());
await act(async () => {});
expect(p.fetchMock).not.toHaveBeenCalled();
expect(useToastStore.getState().toasts).toEqual([]);
p.noDownload();
});
it('falls back to the zip when the browser refuses the picker', async () => {
stubPicker(async () => { throw Object.assign(new Error('refused'), { name: 'SecurityError' }); });
const p = await openViewer();
p.success();
fireEvent.click(saveButton());
await waitFor(() => expect(p.downloads).toHaveLength(1), { timeout: 5000 });
await p.assertZip(['report.txt', 'data.bin'], [alpha, beta]);
});
it('says how many were saved when a write fails midway', async () => {
const folder = fakeFolder('data.bin');
stubPicker(async () => folder);
const p = await openViewer();
p.success();
fireEvent.click(saveButton());
await waitFor(() => expect(useToastStore.getState().toasts).toEqual([
expect.objectContaining({ type: 'warning', title: 'Saved 1 of 2 attachments to Downloads.' }),
]));
expect([...folder.files.keys()]).toEqual(['report.txt']);
});
it('says so when nothing could be written', async () => {
const folder = fakeFolder('report.txt');
stubPicker(async () => folder);
const p = await openViewer();
p.success();
fireEvent.click(saveButton());
await waitFor(() => expect(useToastStore.getState().toasts).toEqual([
expect.objectContaining({ type: 'error', title: 'Could not save the attachments to Downloads.' }),
]));
});
});
+275 -56
View File
@@ -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";
@@ -168,6 +169,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";
/**
@@ -259,6 +261,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;
@@ -291,6 +297,13 @@ export interface ComposerDraftData {
unsaved?: boolean;
}
/** The window a draft moves to; see EmailComposerProps.popOut. */
export interface ComposerPopOut {
/** True once the composer is on screen there. Until then this one keeps the text. */
handOver(data: ComposerDraftData): Promise<boolean>;
cancel(): void;
}
interface EmailComposerProps {
/**
* Submits the message. Resolves `false` when the host sent nothing (it has
@@ -354,6 +367,29 @@ interface EmailComposerProps {
*/
onDiscardDraft?: (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => void;
onSaveState?: (data: ComposerDraftData) => void;
/**
* Offers "Open in new window". Called in the click, since a browser only
* opens a window for a user action: the host opens it and answers with what
* takes the draft, or null when no window opened. The composer then saves,
* hands the draft over under the same draft id, and closes here.
*/
popOut?: () => ComposerPopOut | null;
/**
* This composer is all a window shows (lib/popout). It closes itself once a
* message has gone. Closing the window with unsaved text hands the draft to
* another window to save, or asks first when there is none.
*/
ownWindow?: {
/** The state as the window unloads, for a reload to start from. */
onUnloadState?: (data: ComposerDraftData) => void;
/** A save reached the server, for other windows showing the Drafts folder. */
onDraftSaved?: () => void;
/**
* Hands a draft to another window to save as this one closes: a request
* started now wouldn't survive. False when that can't be done safely.
*/
rescueDraft?: (draft: RescuedDraft) => boolean;
};
className?: string;
initialDraftText?: string;
initialData?: ComposerDraftData | null;
@@ -506,6 +542,8 @@ export function EmailComposer({
requestCloseRef,
onDiscardDraft,
onSaveState,
popOut,
ownWindow,
className,
initialDraftText: initialDraftTextProp,
initialData: initialDataProp,
@@ -519,12 +557,14 @@ export function EmailComposer({
}: EmailComposerProps) {
const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
const tWindows = useTranslations('pwaWindows');
const tQuote = useTranslations('quote_header');
const tViewer = useTranslations('email_viewer');
const tClassification = useTranslations('classification');
const tProofreading = useTranslations('proofreading');
const tOfflineDrafts = useTranslations('offlineDrafts');
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);
@@ -992,11 +1032,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);
@@ -1802,6 +1842,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
@@ -1867,37 +1910,84 @@ export function EmailComposer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effectiveSessionKey]);
const rescueRequestRef = useRef<() => RescuedDraft | null>(() => null);
const closedRef = useRef(false);
// The composer as a host needs it to bring it back: after a remount, in
// another window. Read through refs, so a handler set up once stays current.
const snapshotStateRef = useRef<() => ComposerDraftData>(() => { throw new Error('Not mounted'); });
snapshotStateRef.current = () => ({
...stateRef.current,
draftAccountId: draftAccountIdRef.current,
mode,
replyTo,
priority: priorityRef.current,
classification: classificationRef.current.draftChoice,
// Edits the last save didn't cover; a minimised composer with
// none needs no save before an automatic sign-out.
unsaved: !uneditedRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current),
...(uneditedRef.current ? { unedited: true } : {}),
requestReadReceipt,
requestDsn,
requireTls,
});
// Auto-save state on unmount (when user navigates away without explicitly closing)
useEffect(() => {
return () => {
if (explicitCloseRef.current) return;
// Kept in the host's memory across a remount, handed-in or not. Only
// the server stays out of it.
if (onSaveState && (isDirtyRef.current || uneditedRef.current)) {
const s = stateRef.current;
onSaveState({
...s,
draftAccountId: draftAccountIdRef.current,
mode,
replyTo,
priority: priorityRef.current,
classification: classificationRef.current.draftChoice,
// Edits the last save didn't cover; a minimised composer with
// none needs no save before an automatic sign-out.
unsaved: !uneditedRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current),
...(uneditedRef.current ? { unedited: true } : {}),
});
}
if (onSaveState && (isDirtyRef.current || uneditedRef.current)) onSaveState(snapshotStateRef.current());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-save draft to server on page close (best-effort)
const ownWindowRef = useRef(ownWindow);
ownWindowRef.current = ownWindow;
useEffect(() => {
const handleBeforeUnload = () => {
if (isDirtyRef.current) {
saveDraftRef.current();
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
const own = ownWindowRef.current;
if (!own) {
if (isDirtyRef.current) {
saveDraftRef.current();
}
return;
}
// This composer closed itself: nothing left to keep.
if (closedRef.current) return;
// Closing now could stop a send halfway.
if (isSendingRef.current) {
event.preventDefault();
return;
}
// Sent, discarded or saved and closed.
if (explicitCloseRef.current || sentRef.current) return;
if (!isDirtyRef.current) return;
const state = snapshotStateRef.current();
own.onUnloadState?.(state);
if (!state.unsaved) return;
// A request started while a window closes is dropped, so another window
// saves it, but not when:
// - a plugin may change or stop what is saved, as it only runs here (and
// rescueDraft says no while plugins haven't started);
// - the last save failed or the browser is offline, so that window's
// would likely fail too, with nobody looking;
// - a save is running: its new id is unknown, and two saves make two drafts.
const canHandOver = own.rescueDraft
&& emailHooks.onBeforeDraftAutoSave.size === 0
&& !saveFailedRef.current
&& navigator.onLine !== false
&& !inflightSaveRef.current;
if (canHandOver) {
const draft = rescueRequestRef.current();
if (!draft || own.rescueDraft?.(draft)) return;
}
// Nobody can take it. The prompt keeps the window open: "stay" lets the
// save run, and "leave" is the user's own choice.
saveDraftRef.current();
event.preventDefault();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
@@ -2707,28 +2797,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<string | null> => {
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
@@ -2762,6 +2841,82 @@ export function EmailComposer({
: appendPlainTextSignature(body, signatureIdentity, { separator: signatureSeparatorEnabled }))
: htmlToPlainText(draftHtmlBody!, imageAltText);
return { uploadedAttachments, draftHtmlBody, draftTextBody };
};
// Get the selected identity or primary identity
// Generate sub-addressed email if tag is set
const draftSender = () => {
const identityFromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email
: undefined;
const fromEmail = (fromOverrideEnabled && fromOverrideEmail.trim())
? fromOverrideEmail.trim()
: identityFromEmail;
const fromName = (fromOverrideEnabled && fromOverrideEmail.trim())
? (fromOverrideName.trim() || undefined)
: (currentIdentity?.name || undefined);
return { fromEmail, fromName };
};
// The save below, for another window to make as this one closes. Null with nothing to save.
rescueRequestRef.current = () => {
const { toAddresses, ccAddresses, bccAddresses } = draftRecipients();
if (!draftHasContent(toAddresses)) return null;
const { uploadedAttachments, draftHtmlBody, draftTextBody } = draftContent();
const { fromEmail, fromName } = draftSender();
const login = loginFor(currentIdentityParts.localAccountId);
const previousLogin = draftLocalAccountIdRef.current;
// A version saved through another login can't be replaced from this one.
const replaces = !previousLogin || previousLogin === login;
return {
localAccountId: login,
to: toAddresses,
subject,
body: draftTextBody,
cc: ccAddresses,
bcc: bccAddresses,
identityId: currentIdentityRawId,
fromEmail,
draftId: replaces ? draftIdRef.current || undefined : undefined,
attachments: uploadedAttachments,
fromName,
htmlBody: draftHtmlBody,
inReplyTo: replyThreadingHeaders?.inReplyTo,
references: replyThreadingHeaders?.references,
options: {
priority,
previousDraftAccountId: replaces ? draftAccountIdRef.current : undefined,
classification: classificationRef.current.draftChoice,
},
state: snapshotStateRef.current(),
};
};
// `auto`: the debounced save while typing. Only that one may be skipped
// by a plugin; saves on close and before send always run, or the text
// would be lost.
const saveDraftOnce = async (auto = false): Promise<string | null> => {
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 -
@@ -2781,19 +2936,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;
@@ -2954,6 +3097,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;
@@ -4051,8 +4195,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;
@@ -4080,6 +4226,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]) });
@@ -4104,6 +4252,7 @@ export function EmailComposer({
const afterCloseRef = useRef<(() => void) | null>(null);
const emitClose = () => {
closedRef.current = true;
onClose?.();
const afterClose = afterCloseRef.current;
afterCloseRef.current = null;
@@ -4192,6 +4341,71 @@ export function EmailComposer({
}
};
// Moves the draft to its own window. The window opens first, inside the
// click, and waits; this window saves what the server doesn't have yet,
// gives it the draft under the same id, and closes the composer here.
const [isPoppingOut, setIsPoppingOut] = useState(false);
const poppingOutRef = useRef(false);
const handlePopOut = async () => {
if (!popOut || poppingOutRef.current || isSendingRef.current || saveCloseAttemptRef.current) return;
const target = popOut();
if (!target) {
toast.error(tWindows('popup_blocked'));
return;
}
poppingOutRef.current = true;
setIsPoppingOut(true);
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
try {
const uploads = await waitForPendingUploads(() => attachmentsRef.current, () => false);
if (uploads === 'failed') {
target.cancel();
toast.error(t('save_failed'));
return;
}
if (isDirtyRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current)) {
// A failed save doesn't stop the move: the text goes along, marked
// unsaved, and this composer lets go only once it is on screen there.
await saveDraftRef.current().catch(() => null);
}
const data = snapshotStateRef.current();
const shown = await target.handOver({
...data,
draftId: draftIdRef.current ?? lastDraftIdRef.current,
draftLocalAccountId: draftLocalAccountIdRef.current,
});
// Not on screen there, so this is still the only copy of the text.
if (!shown) {
target.cancel();
toast.error(tWindows('window_not_ready'));
return;
}
explicitCloseRef.current = true;
sendCancelledRef.current = true;
emitClose();
} finally {
poppingOutRef.current = false;
setIsPoppingOut(false);
}
};
const popOutButton = (className: string, iconClassName: string) => popOut && (
<button
type="button"
onClick={(event) => { event.stopPropagation(); void handlePopOut(); }}
disabled={isPoppingOut || isSending}
className={className}
aria-label={tWindows('open_in_window')}
title={tWindows('open_in_window')}
data-testid="composer-open-in-window"
>
<ExternalLink className={iconClassName} />
</button>
);
const handleDiscardAndClose = () => {
cancelSaveAndClose();
sendCancelledRef.current = true;
@@ -4333,6 +4547,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
@@ -4391,6 +4607,7 @@ export function EmailComposer({
<ExternalLink className="h-4 w-4" />
</button>
)}
{popOutButton("grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground", "h-4 w-4")}
<button
type="button"
onClick={() => handleClose()}
@@ -4418,6 +4635,7 @@ export function EmailComposer({
{saveStatus === 'saving' && <Save className="w-3.5 h-3.5 animate-pulse text-muted-foreground flex-shrink-0" aria-label={t('saving')} />}
{mvWindow.state !== "minimized" && leftPanelToggle}
{mvWindow.state !== "minimized" && rightPanelToggle}
{mvWindow.state !== "minimized" && popOutButton("grid h-7 w-7 place-items-center rounded-full text-muted-foreground hover:bg-foreground/10 hover:text-foreground", "h-4 w-4")}
<button
type="button"
onClick={(e) => { e.stopPropagation(); mvWindow.setState(mvWindow.state === "minimized" ? "normal" : "minimized"); }}
@@ -4518,6 +4736,7 @@ export function EmailComposer({
</Button>
)}
{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. */}
<IconButton
+4
View File
@@ -123,6 +123,8 @@ interface EmailContextMenuProps {
copyTargets?: CopyTargetAccount[];
onCopyToAccount?: (accountId: string, mailboxId: string) => 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,
+3
View File
@@ -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!;
+93 -32
View File
@@ -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<Blob | Uint8Array<ArrayBuffer> | null> => {
if (attachment.blobId && blobClient) {
return blobClient.fetchBlob(attachment.blobId, attachment.name || name, attachment.type, blobAccountId);
}
if (attachment.tnefData) return attachment.tnefData as Uint8Array<ArrayBuffer>;
if (attachment.decryptedAttachment) {
const bytes = getAttachmentContentBytes(attachment.decryptedAttachment);
if (bytes && bytes.byteLength > 0) return bytes as Uint8Array<ArrayBuffer>;
}
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<string>();
// 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<HTMLDivElement>(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 ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
{onOpenInWindow && !mvViewer && (
<Button
variant="ghost"
size="sm"
onClick={onOpenInWindow}
className="hidden sm:inline-flex h-8 gap-1.5"
title={tWindows('open_in_window')}
aria-label={tWindows('open_in_window')}
data-testid="viewer-open-in-window"
>
<ExternalLink className="w-4 h-4" />
</Button>
)}
{/* 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) */}
<div className={cn(
"flex items-center justify-between px-4 border-b border-border",
isPaneScoped ? "py-3" : "pb-3 pt-[calc(0.75rem+env(safe-area-inset-top))]"
isPaneScoped ? "py-3" : "pb-3 pt-[calc(0.75rem+var(--app-top-inset))]"
)}>
{moreMenuSub ? (
<button
@@ -4781,6 +4830,18 @@ ${printWatermark ? printWatermark.html : ''}
<Printer className="w-5 h-5" />
</button>
)}
{onOpenInWindow && (
<button
type="button"
onClick={onOpenInWindow}
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={tWindows('open_in_window')}
aria-label={tWindows('open_in_window')}
data-testid="viewer-open-in-window"
>
<ExternalLink className="w-5 h-5" />
</button>
)}
{onToggleFullscreen && (
<button
type="button"
+6
View File
@@ -25,6 +25,7 @@ import {
Tag,
ShieldAlert,
ShieldCheck,
ExternalLink,
CalendarClock,
CalendarX,
Paperclip,
@@ -65,6 +66,7 @@ export type MessageActionKey =
| "spam"
| "report_phishing"
| "find_from_sender"
| "open_window"
| "export"
| "print"
| "view_source"
@@ -115,6 +117,8 @@ export interface MessageActionOptions {
onRescheduleScheduled?: () => void;
onCancelScheduledForEdit?: () => void;
onCancelScheduledSend?: () => void;
/** Opens the message in its own window (installed app, desktop app). */
onOpenInWindow?: () => void;
/** Save the message as .eml. */
onExport?: () => void;
onPrint?: () => void;
@@ -149,6 +153,7 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr
const tViewer = useTranslations("email_viewer");
const tDeepLink = useTranslations("deep_link");
const tPhishing = useTranslations("phishing_report");
const tWindows = useTranslations("pwaWindows");
const copyLink = useCopyLink();
const archiveEnabled = useArchiveActionEnabled();
const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view");
@@ -264,6 +269,7 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr
: [];
// Download/Export, Print, View source (R6: after Copy, before the end).
const file = [
...(o.onOpenInWindow ? [item("open_window", ExternalLink, tWindows("open_in_window"), o.onOpenInWindow, { testId: "ctx-open-window" })] : []),
...(o.onExport ? [item("export", Download, tViewer("export_email"), o.onExport, { disabled: !email.blobId, testId: "ctx-export" })] : []),
...(o.onPrint ? [item("print", Printer, tViewer("print"), o.onPrint, { testId: "ctx-print" })] : []),
...(o.onViewSource ? [item("view_source", Code, tViewer("view_source"), o.onViewSource, { disabled: !email.blobId, testId: "ctx-view-source" })] : []),
+24 -3
View File
@@ -52,6 +52,8 @@ interface ReaderAttachmentsProps<T extends ReaderAttachmentItem> {
onOpen: (attachment: T) => void;
onDownload: (attachment: T) => void;
onDownloadAll: () => void;
/** "Save all to a folder": left out where the browser can't ask for one. */
onSaveAllToFolder?: () => void;
downloadingAll: boolean;
/**
* The account's device policy turned downloads off (Organisation >
@@ -91,6 +93,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
onOpen,
onDownload,
onDownloadAll,
onSaveAllToFolder,
downloadingAll,
downloadsOff = false,
onSaveToFiles,
@@ -101,6 +104,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
const t = useTranslations("email_viewer");
const tFiles = useTranslations("files");
const tDevice = useTranslations("device_policy");
const tWindows = useTranslations("pwaWindows");
// Opens nothing: no preview, and downloads are off.
const locked = (attachment: T) => downloadsOff && !opensPreview(attachment);
const openLabel = (attachment: T, name: string) =>
@@ -238,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" />
@@ -258,10 +262,27 @@ 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 && (
<Button
variant="ghost"
size="sm"
onClick={onSaveAllToFolder}
disabled={downloadingAll}
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" />
<span className="hidden @xl:inline">{tWindows("save_all_to_folder")}</span>
</Button>
)}
</div>
+1 -1
View File
@@ -11,7 +11,7 @@ function AppErrorLayout({ kind, ...props }: FallbackProps & { kind: "page" | "se
const Fallback = kind === "settings" ? SettingsErrorFallback : PageErrorFallback;
// The navigation stays, so one app crashing doesn't strand the user in it.
return (
<div className="flex h-dvh bg-background pt-[env(safe-area-inset-top)]">
<div className="flex h-dvh bg-background pt-[var(--app-top-inset)]">
{!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0 border-e border-border">
<NavigationRail collapsed />
+1 -1
View File
@@ -813,7 +813,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
}
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")}>
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
<AppTopBannerSlot />
{refreshIndicator}
+1 -1
View File
@@ -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. */}
<div
className="flex items-center justify-between gap-2 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+env(safe-area-inset-top))] pb-3 bg-background/90 backdrop-blur border-b border-border"
className="flex items-center justify-between gap-2 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+var(--app-top-inset))] pb-3 bg-background/90 backdrop-blur border-b border-border"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-medium truncate" title={name}>{name}</h3>
+1 -1
View File
@@ -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)]"
>
<div className="flex items-center justify-between gap-3 px-4 h-12 border-b border-border shrink-0">
<h2 id={titleId} className="text-sm font-medium text-foreground truncate">{target.name}</h2>
+48
View File
@@ -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<TitlebarArea | null>(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 <title> 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>
);
}
+43 -5
View File
@@ -151,6 +151,9 @@ import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handler
import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose";
import { onOpenEmail, type OpenEmailHandler, type OpenEmailOutcome } from "@/lib/plugin-sandbox/open-email";
import { mailtoComposerDraft } from "./mailto-draft";
import { useOpenEmailInWindow, usePopoutsAvailable } from "@/hooks/use-popouts-available";
import { openComposerWindow } from "@/lib/popout/open";
import { onRescuedDraft, takeRescuedDraft } from "@/lib/popout/rescued-drafts";
import { getQuoteBodies } from "@/lib/email-composer-utils";
import { emailHooks } from "@/lib/plugin-hooks";
import { afterRequestDispatch } from "@/lib/after-request-dispatch";
@@ -2262,6 +2265,39 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// another account's message under the same id.
const composerSource = usePinnedPerSession(composerSessionId, selectedEmail ? emailWriteSource(selectedEmail, client) : undefined);
const popoutsOn = usePopoutsAvailable();
const openEmailInWindow = useOpenEmailInWindow();
// Text from a closing pop-out that couldn't be saved: the "Continue draft"
// slot, once it is free.
useEffect(() => {
const adopt = () => {
if (pendingDraft || showComposer) return;
const draft = takeRescuedDraft();
if (draft) setPendingDraft(draft);
};
adopt();
return onRescuedDraft(adopt);
}, [pendingDraft, showComposer]);
const composerPopOut = () => {
const target = openComposerWindow(activeAccountId);
if (!target) return null;
return {
cancel: target.cancel,
handOver: (state: ComposerDraftData) => target.handOver({
mode: state.mode,
replyTo: state.replyTo,
initialData: state,
sourceEmailId: composerSourceEmail?.id,
sourceEmailSource: composerSource,
composeFromAccountEmail: resolveComposeAccountEmail(
mailboxes,
selectedMailbox,
useAccountStore.getState().getAccountById(viewingAccountId ?? activeAccountId ?? '')?.email,
),
}),
};
};
const handleEmailSend = async (data: {
to: string[];
cc: string[];
@@ -5026,6 +5062,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
handleDiscardDraft(draftId, draft);
setPendingDraft(null);
}}
popOut={popoutsOn && !isEmbedded ? composerPopOut : undefined}
/>
</ErrorBoundary>
);
@@ -5036,7 +5073,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"
@@ -5149,7 +5186,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.
@@ -5160,7 +5197,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
@@ -5302,7 +5339,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">
@@ -5664,7 +5701,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
@@ -5849,6 +5886,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}
@@ -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>,
@@ -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();
});
});
@@ -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');
+18 -7
View File
@@ -6,8 +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 { useOfflineStore } from '@/lib/offline/state';
import { confirmLoss, setOfflineNotifier, startOfflineMode, unsentCount, type OfflineNotice } from '@/lib/offline/offline-mode';
import { toast } from '@/stores/toast-store';
/**
@@ -65,6 +64,15 @@ 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;
case 'store-unavailable':
toast.warning(tConflictsRef.current('store_unavailable'), { duration: 0 });
break;
case 'refused-delete':
toast.warning(tr('toast.refused_delete'));
break;
@@ -86,13 +94,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 { outbox, localDrafts } = useOfflineStore.getState();
const unsent = [...outbox, ...localDrafts].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',
});
+5 -1
View File
@@ -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, ...s.localDrafts].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">
+1 -1
View File
@@ -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
@@ -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();
});
});
@@ -0,0 +1,319 @@
import { act, cleanup, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
import { openPopoutChannel, requestWindowSession, watchMainWindow, askRescuedDraftId, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel';
import { takeRescuedDraft } from '@/lib/popout/rescued-drafts';
import { createSessionOpener } from '@/lib/popout/sealed';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useToastStore } from '@/stores/toast-store';
import { PopoutHost } from '../popout-host';
// The main window's side of pop-outs. The next-intl mock returns the key.
const DRAFT: RescuedDraft = {
localAccountId: 'alice',
to: ['a@example.test'], subject: 'Closing', body: 'text', cc: [], bcc: [], draftId: 'old', attachments: [],
state: { subject: 'Closing' } as never,
};
const original = { auth: useAuthStore.getState(), email: useEmailStore.getState(), accounts: useAccountStore.getState() };
let heard: PopoutMessage[];
let popout: NonNullable<ReturnType<typeof openPopoutChannel>>;
let client: { createDraft: ReturnType<typeof vi.fn>; getAuthHeader: () => string; isOfflineOnly: () => boolean };
let lockRequests: string[];
// Rounds, not one long wait: a message and its answer are two hops, and a busy
// machine can run a single timer before the second hop is even queued.
const settle = async (ms = 10) => {
for (let round = 0; round < 5; round++) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, ms));
});
}
};
/** Web Locks with one holder per name, as a browser has. */
function stubLocks() {
const held = new Set<string>();
lockRequests = [];
Object.defineProperty(navigator, 'locks', {
configurable: true,
value: {
request(name: string, options: { ifAvailable?: boolean }, callback: (lock: unknown) => Promise<unknown>) {
lockRequests.push(name);
if (options.ifAvailable && held.has(name)) return Promise.resolve(callback(null));
held.add(name);
return Promise.resolve(callback({}));
},
},
});
}
beforeEach(() => {
FakeBroadcastChannel.reset();
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
// The bridge makes pop-outs available without stubbing the display mode.
window.bulwarkDesktop = { version: 1, platform: 'win32', windows: {} };
stubLocks();
sessionStorage.clear();
heard = [];
popout = openPopoutChannel((message) => heard.push(message))!;
client = { createDraft: vi.fn(async () => 'new'), getAuthHeader: () => 'Bearer main', isOfflineOnly: () => false };
useAccountStore.setState({
accounts: [{ id: 'alice', serverUrl: 'https://mail.example.test', username: 'alice', authMode: 'oauth', cookieSlot: 0 } as never],
activeAccountId: 'alice',
});
useAuthStore.setState({
isAuthenticated: true,
isDemoMode: false,
activeAccountId: 'alice',
client: client as never,
getAllConnectedClients: () => new Map([['alice', client]]) as never,
getClientForAccount: (id: string) => (id === 'alice' ? client : undefined) as never,
});
useEmailStore.setState({ refreshCurrentMailbox: vi.fn(async () => {}) as never, fetchMailboxes: vi.fn(async () => {}) as never, pendingUndoSend: null });
useToastStore.getState().clearToasts();
});
afterEach(() => {
cleanup();
popout.close();
delete window.bulwarkDesktop;
useAuthStore.setState(original.auth);
useEmailStore.setState(original.email);
useAccountStore.setState(original.accounts);
vi.unstubAllGlobals();
Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined });
});
const answers = () => heard.filter((message) => message.type === 'session-answer');
describe('in an ordinary browser tab', () => {
it('runs nothing: no channel and no lock', async () => {
delete window.bulwarkDesktop;
const opened = FakeBroadcastChannel.open.size;
render(<PopoutHost />);
await settle();
expect(FakeBroadcastChannel.open.size).toBe(opened);
expect(lockRequests).toEqual([]);
popout.post({ type: 'main-ping' });
await settle();
expect(heard).toEqual([]);
});
});
describe('PopoutHost', () => {
it('holds no lock while idle, which would keep the page out of the back/forward cache', async () => {
render(<PopoutHost />);
await settle();
expect(lockRequests).toEqual([]);
});
it('lends the session to a pop-out that asks, sealed', async () => {
render(<PopoutHost />);
await settle();
await expect(requestWindowSession({}, 2000)).resolves.toMatchObject({
activeAccountId: 'alice',
sessions: [{ accountId: 'alice', authHeader: 'Bearer main', authMode: 'oauth' }],
});
expect(JSON.stringify(heard)).not.toContain('Bearer main');
});
it('says nothing while it has no session to lend, so a pop-out goes on to the cookies', async () => {
useAuthStore.setState({ isAuthenticated: false });
render(<PopoutHost />);
await settle();
await expect(requestWindowSession({}, 100)).resolves.toBeNull();
});
it('says nothing about a login it does not hold, so the window that holds it answers first', async () => {
render(<PopoutHost />);
await settle();
await expect(requestWindowSession({ accountId: 'bob' }, 100)).resolves.toBeNull();
expect(answers()).toEqual([]);
});
it('renews a token the server refused, once, in this window', async () => {
const refresh = vi.fn(async () => 'next');
useAuthStore.setState({ refreshAccessToken: refresh as never });
render(<PopoutHost />);
await settle();
await requestWindowSession({ accountId: 'alice', rejectedAuthHeader: 'Bearer main' }, 2000);
expect(refresh).toHaveBeenCalledWith({ accountId: 'alice' });
refresh.mockClear();
await requestWindowSession({ accountId: 'alice', rejectedAuthHeader: 'Bearer older' }, 2000);
expect(refresh).not.toHaveBeenCalled();
});
it('saves a closing window\'s draft, replacing its version', async () => {
render(<PopoutHost />);
await settle();
popout.post({ type: 'draft-rescue', draft: DRAFT });
await settle();
expect(client.createDraft).toHaveBeenCalledTimes(1);
expect(client.createDraft.mock.calls[0].slice(0, 2)).toEqual([['a@example.test'], 'Closing']);
expect(client.createDraft.mock.calls[0][7]).toBe('old');
});
it('saves it once with two main windows open', async () => {
render(<><PopoutHost /><PopoutHost /></>);
await settle();
popout.post({ type: 'draft-rescue', draft: { ...DRAFT, rescueKey: 'k' } });
await settle();
expect(client.createDraft).toHaveBeenCalledTimes(1);
});
it('keeps the text for the "Continue draft" slot when the save fails, and says so', async () => {
client.createDraft.mockRejectedValueOnce(new Error('Server down'));
vi.spyOn(console, 'error').mockImplementation(() => {});
render(<PopoutHost />);
await settle();
popout.post({ type: 'draft-rescue', draft: DRAFT });
await settle();
expect(useToastStore.getState().toasts.map((toast) => toast.type)).toEqual(['error']);
expect(takeRescuedDraft()).toMatchObject({ subject: 'Closing', unsaved: true });
});
it('keeps the text too when the login is gone by then', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
useAuthStore.setState({ getClientForAccount: () => undefined });
render(<PopoutHost />);
await settle();
popout.post({ type: 'draft-rescue', draft: DRAFT });
await settle();
expect(takeRescuedDraft()).toMatchObject({ subject: 'Closing' });
});
it('tells a reloaded pop-out which id its text was saved under', async () => {
render(<PopoutHost />);
await settle();
popout.post({ type: 'draft-rescue', draft: { ...DRAFT, rescueKey: 'k1' } });
await settle();
await expect(askRescuedDraftId('k1', 1000)).resolves.toBe('new');
await expect(askRescuedDraftId('nobody', 50)).resolves.toBeNull();
});
it('takes over the send\'s outcome once, and offers Undo here', async () => {
render(<><PopoutHost /><PopoutHost /></>);
await settle();
const pending = { submissionId: 's', sendAt: '2030-01-01T00:00:00Z', isSmime: false };
popout.post({ type: 'sent', requestId: 'r1', notice: { accountId: 'alice', pendingUndoSend: pending } });
await settle();
expect(heard.filter((message) => message.type === 'sent-ack')).toHaveLength(1);
expect(useEmailStore.getState().pendingUndoSend).toEqual(pending);
expect(useToastStore.getState().toasts).toEqual([]);
useEmailStore.setState({ pendingUndoSend: null });
popout.post({ type: 'sent', requestId: 'r2', notice: { accountId: 'alice' } });
await settle();
expect(useToastStore.getState().toasts).toHaveLength(1);
});
it('refreshes the list and counts when a pop-out changed mail, once for a burst', async () => {
render(<PopoutHost />);
await settle();
popout.post({ type: 'mail-changed', accountId: 'alice' });
popout.post({ type: 'mail-changed', accountId: 'alice' });
await settle(400);
expect(useEmailStore.getState().refreshCurrentMailbox).toHaveBeenCalledTimes(1);
expect(useEmailStore.getState().fetchMailboxes).toHaveBeenCalledTimes(1);
});
it('tells pop-outs when its own list changed, since no push reports that to them', async () => {
render(<PopoutHost />);
await settle();
act(() => useEmailStore.setState({ emails: [{ id: 'x' } as never] }));
await settle(120);
expect(heard).toContainEqual({ type: 'state-change' });
});
it('tells pop-outs when an account went away', async () => {
render(<PopoutHost />);
await settle();
act(() => useAccountStore.setState({ accounts: [] }));
await settle();
expect(heard).toContainEqual({ type: 'account-removed', accountId: 'alice' });
});
});
describe('watchMainWindow', () => {
it('knows a main window is there, and hands it the draft', async () => {
render(<PopoutHost />);
await settle();
const main = watchMainWindow();
await settle();
expect(main.alive()).toBe(true);
expect(main.rescue(DRAFT)).toBe(true);
await settle();
expect(client.createDraft).toHaveBeenCalledTimes(1);
main.stop();
});
it('won\'t hand a draft to a window that doesn\'t hold its login', async () => {
render(<PopoutHost />);
await settle();
const main = watchMainWindow();
await settle();
expect(main.alive('alice')).toBe(true);
expect(main.alive('bob')).toBe(false);
expect(main.rescue({ ...DRAFT, localAccountId: 'bob' })).toBe(false);
await settle();
expect(client.createDraft).not.toHaveBeenCalled();
main.stop();
});
it('knows at once when the last one closed, and takes nothing then', async () => {
render(<PopoutHost />);
await settle();
const main = watchMainWindow();
await settle();
await act(async () => { window.dispatchEvent(new Event('pagehide')); });
cleanup();
await settle();
expect(main.alive()).toBe(false);
expect(main.rescue(DRAFT)).toBe(false);
expect(client.createDraft).not.toHaveBeenCalled();
main.stop();
});
it('is not fooled by a closing window that still runs long enough to hear the ping', async () => {
render(<PopoutHost />);
await settle();
const main = watchMainWindow();
await settle();
// Still mounted: the page is on its way out, its scripts haven't stopped.
await act(async () => { window.dispatchEvent(new Event('pagehide')); });
const late = openPopoutChannel(() => {})!;
late.post({ type: 'main-ping' });
await settle();
late.close();
expect(main.alive()).toBe(false);
expect(main.rescue(DRAFT)).toBe(false);
main.stop();
});
it('takes nothing with no main window from the start', async () => {
const main = watchMainWindow();
await settle();
expect(main.rescue(DRAFT)).toBe(false);
main.stop();
});
});
describe('sealed answers', () => {
it('open only for the window that asked', async () => {
render(<PopoutHost />);
await settle();
const other = await createSessionOpener();
const mine = await createSessionOpener();
popout.post({ type: 'session-request', requestId: 'r', publicKey: mine.publicKey });
await settle();
const [answer] = answers();
expect(answer).toBeTruthy();
if (answer.type !== 'session-answer') throw new Error('unreachable');
await expect(mine.open(answer.sealed)).resolves.toMatchObject({ activeAccountId: 'alice' });
await expect(other.open(answer.sealed)).rejects.toThrow();
});
});
@@ -0,0 +1,183 @@
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Email } from '@/lib/jmap/types';
import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
import { openPopoutChannel } from '@/lib/popout/channel';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
// The pop-out message around the view it hosts: the view is stubbed, since the
// Pro tab's tests cover it. The next-intl mock returns the key.
type ViewProps = {
emailId: string;
clientAccountId?: string;
accountId?: string;
liveState?: { keywords?: Record<string, boolean>; mailboxIds?: Record<string, boolean> } | null;
onLoaded?: (email: Email) => void;
onClose: () => void;
onOpenCompose?: (data: { mode: string }) => void;
onMailChanged?: () => void;
};
let view: ViewProps;
vi.mock('@/components/pro/pro-email-tab-body', () => ({
ProEmailView: (props: ViewProps) => {
view = props;
return <div data-testid="message-view" />;
},
}));
vi.mock('../popout-shell', () => ({
PopoutShell: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PopoutNotice: ({ title, children }: { title: string; children: React.ReactNode }) => <div><h1>{title}</h1>{children}</div>,
PopoutSkeleton: () => <div data-testid="skeleton" />,
}));
vi.mock('@/hooks/use-document-title', () => ({ useDocumentTitle: vi.fn() }));
const { PopoutMessageApp } = await import('../popout-message-app');
const original = { auth: useAuthStore.getState(), email: useEmailStore.getState() };
const EMAIL = { id: 'm/1', subject: 'Hello', keywords: { $seen: true }, mailboxIds: { inbox: true } } as unknown as Email;
let found: Array<Partial<Email>> | Error;
let client: { getSomeEmails: ReturnType<typeof vi.fn> };
const settle = async () => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
});
};
beforeEach(() => {
FakeBroadcastChannel.reset();
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
window.history.replaceState(null, '', '/en/popout/message/m%2F1?account=login-b&owner=group');
found = [EMAIL];
client = {
getSomeEmails: vi.fn(async () => {
if (found instanceof Error) throw found;
return found;
}),
};
useAuthStore.setState({
client: client as never,
activeAccountId: 'login-a',
getClientForAccount: (id: string) => (id === 'login-b' ? client : undefined) as never,
});
useEmailStore.setState({
emails: [],
mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox' }, { id: 'archive', name: 'Old mail', role: null }] as never,
fetchMailboxes: vi.fn(async () => {}) as never,
});
});
afterEach(() => {
cleanup();
useAuthStore.setState(original.auth);
useEmailStore.setState(original.email);
vi.unstubAllGlobals();
window.history.replaceState(null, '', '/');
});
describe('PopoutMessageApp', () => {
it('opens the message of the address, through its login and account', async () => {
render(<PopoutMessageApp id="rendered-for-another-id" />);
await settle();
expect(view.emailId).toBe('m/1');
expect(view.clientAccountId).toBe('login-b');
expect(view.accountId).toBe('group');
expect(client.getSomeEmails).toHaveBeenCalledWith(['m/1'], 'group');
});
it('shows the message once the folders are in: Delete needs Trash, and the toolbar must not move', async () => {
let finish: () => void = () => {};
useEmailStore.setState({ mailboxes: [], fetchMailboxes: vi.fn(() => new Promise<void>((resolve) => { finish = resolve; })) as never });
render(<PopoutMessageApp id="m1" />);
await settle();
expect(screen.queryByTestId('message-view')).toBeNull();
expect(screen.getByTestId('skeleton')).toBeTruthy();
await act(async () => { finish(); });
await settle();
expect(screen.getByTestId('message-view')).toBeTruthy();
});
it('shows the message anyway when the folders cannot be fetched', async () => {
useEmailStore.setState({ mailboxes: [], fetchMailboxes: vi.fn(async () => { throw new Error('offline'); }) as never });
render(<PopoutMessageApp id="m1" />);
await settle();
expect(screen.getByTestId('message-view')).toBeTruthy();
});
it('puts the loaded message where delete, move, star and read look for it', async () => {
render(<PopoutMessageApp id="m1" />);
await settle();
act(() => view.onLoaded?.(EMAIL));
expect(useEmailStore.getState().emails).toEqual([EMAIL]);
expect(useEmailStore.getState().selectedMailbox).toBe('inbox');
});
it('says so when the message is gone', async () => {
found = [];
render(<PopoutMessageApp id="m1" />);
await screen.findByText('message_gone_title');
expect(screen.queryByTestId('message-view')).toBeNull();
});
it('keeps the message on screen when the server does not answer', async () => {
found = new TypeError('Failed to fetch');
render(<PopoutMessageApp id="m1" />);
await settle();
expect(screen.getByTestId('message-view')).toBeTruthy();
expect(screen.queryByText('message_gone_title')).toBeNull();
});
it('checks again when another window reports a change, and shows the new flags', async () => {
const main = openPopoutChannel(() => {})!;
render(<PopoutMessageApp id="m1" />);
await settle();
act(() => view.onLoaded?.(EMAIL));
found = [{ ...EMAIL, keywords: { $seen: false } }];
main.post({ type: 'state-change' });
await waitFor(() => expect(view.liveState?.keywords).toEqual({ $seen: false }));
// And the next toggle starts from them.
expect(useEmailStore.getState().emails[0].keywords).toEqual({ $seen: false });
main.close();
});
it('checks again when the window gets the focus', async () => {
render(<PopoutMessageApp id="m1" />);
await settle();
found = [];
act(() => { window.dispatchEvent(new Event('focus')); });
await screen.findByText('message_gone_title');
});
it('says where a moved message went, and keeps showing it', async () => {
render(<PopoutMessageApp id="m1" />);
await settle();
expect(document.querySelector('[data-popout-moved]')).toBeNull();
found = [{ ...EMAIL, mailboxIds: { archive: true } }];
act(() => { window.dispatchEvent(new Event('focus')); });
await waitFor(() => expect(document.querySelector('[data-popout-moved]')?.textContent).toContain('message_moved_to'));
expect(screen.getByTestId('message-view')).toBeTruthy();
});
it('tells the main window when it changed something', async () => {
const heard: string[] = [];
const main = openPopoutChannel((message) => heard.push(message.type))!;
render(<PopoutMessageApp id="m1" />);
await settle();
act(() => view.onMailChanged?.());
await settle();
expect(heard).toContain('mail-changed');
main.close();
});
it('closes the window when the message leaves it', async () => {
const close = vi.spyOn(window, 'close').mockImplementation(() => {});
render(<PopoutMessageApp id="m1" />);
await settle();
act(() => view.onClose());
expect(close).toHaveBeenCalledTimes(1);
close.mockRestore();
});
});
@@ -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();
});
});
+15
View File
@@ -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}</>;
}
+222
View File
@@ -0,0 +1,222 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer";
import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePluginStore } from "@/stores/plugin-store";
import { toast } from "@/stores/toast-store";
import { formatRejectedRecipients } from "@/lib/jmap/client";
import { debug } from "@/lib/debug";
import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
import { announceSent } from "@/lib/plugin-after-send";
import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type RescuedDraft } from "@/lib/popout/channel";
import { acknowledgeComposeHandoff, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff";
import type { ComposePopoutPayload } from "@/lib/popout/compose-payload";
import { closePopoutWindow } from "@/lib/popout/window-role";
import { PopoutShell, PopoutSkeleton } from "./popout-shell";
/** A warning with no main window left to show it stays this long before the window goes. */
const WARNING_READ_MS = 8000;
type ComposerProps = React.ComponentProps<typeof EmailComposer>;
type SendData = Parameters<NonNullable<ComposerProps["onSend"]>>[0];
const EMPTY_PAYLOAD: ComposePopoutPayload = { mode: "compose" };
/** How long a reloaded window waits to hear what became of the text it handed over on the way out. */
const RESCUE_ANSWER_MS = 2000;
function readLaunch(): { handoffId: string | null; accountId?: string } {
if (typeof window === "undefined") return { handoffId: null };
const search = new URLSearchParams(window.location.search);
return { handoffId: search.get("handoff"), accountId: search.get("account") ?? undefined };
}
/** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */
function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; handoffId: string | null }) {
const t = useTranslations();
const client = useAuthStore((s) => s.client);
const [sessionKey] = useState(() => `popout-${handoffId ?? Date.now().toString(36)}`);
const mode = payload.initialData?.mode ?? payload.mode;
useDocumentTitle(
mode === "reply" ? t("email_composer.reply")
: mode === "replyAll" ? t("email_composer.reply_all")
: mode === "forward" ? t("email_composer.forward")
: t("email_composer.new_message"),
);
const handleSend = useCallback(async (data: SendData) => {
if (!client) return false;
const sendClient = data.localAccountId
? (useAuthStore.getState().getClientForAccount(data.localAccountId) ?? client)
: client;
const emailStore = useEmailStore.getState();
// A send that fails goes to the composer, which stays open with the text.
const result = await emailStore.sendEmail(
sendClient,
data.to,
data.subject,
data.body,
data.cc,
data.bcc,
data.identityId,
data.fromEmail,
data.draftId,
data.fromName,
data.htmlBody,
data.attachments,
data.inReplyTo,
data.references,
data.delayedUntil,
data.envelopeMailFrom,
composerSendOptions(data),
);
announceSent(result, data);
if (payload.sourceEmailId && (mode === "reply" || mode === "replyAll" || mode === "forward")) {
const keyword = mode === "forward" ? "$forwarded" : "$answered";
try {
await useEmailStore.getState().markEmailKeyword(client, payload.sourceEmailId, keyword, payload.sourceEmailSource ?? undefined);
} catch (error) {
debug.error(`Failed to set ${keyword} keyword:`, error);
}
}
const accountId = data.localAccountId ?? useAuthStore.getState().activeAccountId;
const pending = useEmailStore.getState().pendingUndoSend;
const rejectedRecipients = result.rejectedRecipients?.length ? formatRejectedRecipients(result.rejectedRecipients) : undefined;
const taken = await announceSentToMainWindow({
accountId,
scheduled: !!result.scheduled,
queued: !!result.queued,
filingError: !!result.filingError,
rejectedRecipients,
pendingUndoSend: pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null,
});
// No main window to say so, and this is the only notice these get (#1123).
if (!taken && (result.filingError || rejectedRecipients)) {
if (result.filingError) toast.warning(t("email_composer.send_filing_warning"));
if (rejectedRecipients) toast.warning(t("email_composer.send_some_recipients_rejected"), { message: rejectedRecipients, duration: WARNING_READ_MS });
await new Promise((resolve) => setTimeout(resolve, WARNING_READ_MS));
}
}, [client, mode, payload.sourceEmailId, payload.sourceEmailSource, t]);
const handleDiscardDraft = useCallback(async (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => {
if (!client) return;
// Through the login the draft was saved with, and never another: the id names another message there.
const draftClient = draft?.localAccountId
? useAuthStore.getState().getClientForAccount(draft.localAccountId)
: client;
if (!draftClient) {
toast.error(t("email_composer.discard_signed_out"));
return;
}
try {
await draftClient.deleteEmail(draftId, draft?.accountId);
} catch (error) {
console.error("Failed to discard draft:", error);
}
}, [client, t]);
const handleClose = useCallback(() => {
announceMailChanged(useAuthStore.getState().activeAccountId);
closePopoutWindow();
}, []);
const [mainWindow] = useState(watchMainWindow);
useEffect(() => () => mainWindow.stop(), [mainWindow]);
// The window that opened this one lets go of its copy now. Not without a
// session: this one couldn't save or send the text.
useEffect(() => {
if (handoffId && client) acknowledgeComposeHandoff(handoffId);
}, [handoffId, client]);
const ownWindow = useMemo(() => ({
rescueDraft: (draft: RescuedDraft) => {
// A plugin that hasn't started may yet have a say in what a draft holds,
// and it can only have it in this window's save.
if (!usePluginStore.getState().initialized) return false;
return mainWindow.rescue({
...draft,
localAccountId: draft.localAccountId ?? useAuthStore.getState().activeAccountId ?? undefined,
rescueKey: handoffId ?? undefined,
});
},
// A reload of this window starts from where the text is now.
onUnloadState: (state: ComposerDraftData) => {
if (handoffId) rememberComposePayload(handoffId, { ...payload, initialData: state, prefill: null });
},
onDraftSaved: () => announceMailChanged(useAuthStore.getState().activeAccountId),
}), [handoffId, mainWindow, payload]);
return (
<ErrorBoundary fallback={ComposerErrorFallback}>
<EmailComposer
mode={mode}
replyTo={payload.replyTo}
initialDraftText={payload.initialDraftText}
initialData={payload.initialData}
prefill={payload.prefill}
sessionKey={sessionKey}
sourceEmailId={payload.sourceEmailId ?? undefined}
composeFromAccountEmail={payload.composeFromAccountEmail
?? useAccountStore.getState().getAccountById(useAuthStore.getState().activeAccountId ?? "")?.email}
onSend={handleSend}
onClose={handleClose}
onDiscardDraft={handleDiscardDraft}
ownWindow={ownWindow}
className="min-h-0 flex-1"
/>
</ErrorBoundary>
);
}
/** `/popout/compose`: a draft handed over by another window, or a new message. */
export function PopoutComposeApp() {
const t = useTranslations("pwaWindows");
const [{ handoffId, accountId }] = useState(readLaunch);
const [payload, setPayload] = useState<ComposePopoutPayload | null>(handoffId ? null : EMPTY_PAYLOAD);
useEffect(() => {
if (!handoffId) return;
let cancelled = false;
const reloaded = rememberedComposePayload(handoffId) !== null;
void receiveComposeHandoff(handoffId).then(async (received) => {
if (!received) {
// Called off or never ready: the composer stayed where it was.
if (!cancelled) closePopoutWindow();
return;
}
// Reloaded: the text may have gone to a main window on the way out, under
// a new id. Saving under the old one would leave two drafts.
const draftId = reloaded && received.initialData ? await askRescuedDraftId(handoffId, RESCUE_ANSWER_MS) : null;
if (cancelled) return;
setPayload(draftId && received.initialData
? { ...received, initialData: { ...received.initialData, draftId, draftAccountId: undefined, unsaved: false } }
: received);
});
// The opening window takes the draft back. Gone first, so that closing
// doesn't save a copy of what that window still has.
const stopWatching = watchHandoffCancel(handoffId, () => {
setPayload(null);
setTimeout(closePopoutWindow, 0);
});
return () => {
cancelled = true;
stopWatching();
};
}, [handoffId]);
return (
<PopoutShell kind="compose" accountId={accountId}>
{payload ? <PopoutComposer payload={payload} handoffId={handoffId} /> : <PopoutSkeleton label={t("opening")} />}
</PopoutShell>
);
}
+222
View File
@@ -0,0 +1,222 @@
"use client";
import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { usePopoutsAvailable } from "@/hooks/use-popouts-available";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { newPopoutId, openPopoutChannel, type PopoutChannel, type PopoutMessage, type PopoutSentNotice, type RescuedDraft, type SharedAccountSession } from "@/lib/popout/channel";
import { keepRescuedDraft } from "@/lib/popout/rescued-drafts";
import { credentialFingerprint, sealFor } from "@/lib/popout/sealed";
import { changesAnyType, subscribePushStateChanges } from "@/lib/push-events";
/** At most this often: a pop-out's autosave reports every few seconds. */
const REFRESH_THROTTLE_MS = 1500;
/** What the list changing in this window waits for, so a burst tells pop-outs once. */
const LIST_CHANGE_DEBOUNCE_MS = 400;
/** How long the claim on a one-off job stays held: longer than a message takes to reach every window. */
const CLAIM_HOLD_MS = 15_000;
/** How long a window remembers which draft id a rescue became, for a reload of the pop-out. */
const RESCUE_MEMORY_MS = 60_000;
/** The sessions this window can lend. Empty until it is signed in, and in the demo. */
function shareableSessions(): SharedAccountSession[] {
const auth = useAuthStore.getState();
if (!auth.isAuthenticated || auth.isDemoMode) return [];
const sessions: SharedAccountSession[] = [];
for (const [accountId, client] of auth.getAllConnectedClients()) {
const account = useAccountStore.getState().getAccountById(accountId);
if (!account) continue;
sessions.push({
accountId,
serverUrl: account.serverUrl,
username: account.username,
authMode: account.authMode,
authHeader: client.getAuthHeader(),
...(client.isOfflineOnly() ? { offlineOnly: true } : {}),
});
}
return sessions;
}
/**
* Runs `work` in one of the main windows, the first to claim `name`. Held on
* for a while afterwards, or a slower window would find it free. A browser
* releases it with the window, so no window keeps a lock for long (one held for
* life keeps a page out of the back/forward cache).
*/
function claimOnce(name: string, work: () => void | Promise<void>): void {
void navigator.locks?.request(name, { ifAvailable: true }, async (lock) => {
if (!lock) return;
await work();
await new Promise((resolve) => setTimeout(resolve, CLAIM_HOLD_MS));
}).catch(() => {});
}
/**
* The main window's side of pop-outs: lends them its session and renews it
* for them, passes on that mail changed, and takes over what a pop-out can't
* say any more once it has closed. Only where pop-outs are on offer: an
* ordinary browser tab runs none of this.
*/
export function PopoutHost() {
const available = usePopoutsAvailable();
const t = useTranslations();
const tRef = useRef(t);
tRef.current = t;
useEffect(() => {
if (!available) return;
let channel: PopoutChannel | null = null;
const windowId = newPopoutId();
let closing = false;
const rescues = new Map<string, Promise<string | null>>();
const answerSessionRequest = async (message: Extract<PopoutMessage, { type: "session-request" }>) => {
const { accountId, rejectedFingerprint } = message;
const auth = useAuthStore.getState();
// Only a window holding the login answers: the first answer has to be usable.
if (accountId && !shareableSessions().some((session) => session.accountId === accountId)) return;
// The server refused what the pop-out holds. If that is what this window
// holds too, renew it here, where the timers and the sign-out live.
if (accountId && rejectedFingerprint) {
const current = auth.getClientForAccount(accountId)?.getAuthHeader();
if (current && await credentialFingerprint(current) === rejectedFingerprint) await auth.refreshAccessToken({ accountId });
}
const sessions = shareableSessions();
if (sessions.length === 0) return;
const sealed = await sealFor(message.publicKey, { activeAccountId: useAuthStore.getState().activeAccountId, sessions });
channel?.post({ type: "session-answer", requestId: message.requestId, sealed });
};
// List and counts, so read state and Drafts follow what a pop-out did.
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
const refreshList = () => {
if (refreshTimer) return;
refreshTimer = setTimeout(() => {
refreshTimer = null;
const client = useAuthStore.getState().client;
if (!client) return;
const emails = useEmailStore.getState();
void emails.refreshCurrentMailbox(client);
void emails.fetchMailboxes(client);
}, REFRESH_THROTTLE_MS);
};
const takeOverSent = (notice: PopoutSentNotice) => {
const translate = tRef.current;
if (notice.filingError) toast.warning(translate("email_composer.send_filing_warning"));
if (notice.rejectedRecipients) {
toast.warning(translate("email_composer.send_some_recipients_rejected"), { message: notice.rejectedRecipients, duration: 15000 });
}
// Undo lives here now: the window that sent it is closing.
if (notice.pendingUndoSend) useEmailStore.setState({ pendingUndoSend: notice.pendingUndoSend });
else if (!notice.queued && !notice.scheduled && !notice.filingError && !notice.rejectedRecipients) toast.success(translate("notifications.email_sent"));
refreshList();
};
const saveRescuedDraft = async (draft: RescuedDraft): Promise<string | null> => {
const auth = useAuthStore.getState();
const client = draft.localAccountId ? auth.getClientForAccount(draft.localAccountId) : auth.client;
try {
if (!client) throw new Error("No login to save through");
const draftId = await client.createDraft(
draft.to, draft.subject, draft.body, draft.cc, draft.bcc, draft.identityId, draft.fromEmail, draft.draftId,
draft.attachments, draft.fromName, draft.htmlBody, draft.inReplyTo, draft.references, draft.options,
);
refreshList();
return draftId;
} catch (error) {
// The text exists nowhere else now.
console.error("Failed to save a closing window's draft:", error);
keepRescuedDraft(draft.state);
toast.error(tRef.current("email_composer.save_failed"));
return null;
}
};
const startRescue = (draft: RescuedDraft) => {
const claim = draft.rescueKey ?? newPopoutId();
claimOnce(`bulwark-popout-rescue:${claim}`, async () => {
const saved = saveRescuedDraft(draft);
if (draft.rescueKey) {
rescues.set(draft.rescueKey, saved);
setTimeout(() => rescues.delete(claim), RESCUE_MEMORY_MS);
}
await saved;
});
};
channel = openPopoutChannel((message) => {
switch (message.type) {
case "session-request":
void answerSessionRequest(message);
break;
case "sent":
claimOnce(`bulwark-popout-sent:${message.requestId}`, () => {
channel?.post({ type: "sent-ack", requestId: message.requestId });
takeOverSent(message.notice);
});
break;
case "draft-rescue":
startRescue(message.draft);
break;
case "rescue-query": {
const rescue = rescues.get(message.rescueKey);
if (rescue) void rescue.then((draftId) => channel?.post({ type: "rescue-answer", rescueKey: message.rescueKey, draftId }));
break;
}
case "main-ping":
if (!closing) channel?.post({ type: "main-pong", windowId, accountIds: shareableSessions().map((session) => session.accountId) });
break;
case "mail-changed":
refreshList();
break;
}
});
if (!channel) return;
const stopPush = subscribePushStateChanges((change) => {
if (changesAnyType(change, ["Email", "Mailbox"])) channel?.post({ type: "state-change" });
});
// This window's own changes reach no push stream on the way to a pop-out.
let listTimer: ReturnType<typeof setTimeout> | null = null;
const stopList = useEmailStore.subscribe((state, prev) => {
if (state.emails === prev.emails && state.selectedEmail === prev.selectedEmail) return;
if (listTimer) clearTimeout(listTimer);
listTimer = setTimeout(() => channel?.post({ type: "state-change" }), LIST_CHANGE_DEBOUNCE_MS);
});
const stopAccounts = useAccountStore.subscribe((state, prev) => {
if (state.accounts === prev.accounts) return;
const ids = new Set(state.accounts.map((account) => account.id));
for (const account of prev.accounts) {
if (!ids.has(account.id)) channel?.post({ type: "account-removed", accountId: account.id });
}
});
// Hearing a ping late in closing, this window must not answer: a draft
// handed to it now would be lost.
const onPageHide = () => {
closing = true;
channel?.post({ type: "main-gone", windowId });
};
const onPageShow = () => {
closing = false;
};
window.addEventListener("pagehide", onPageHide);
window.addEventListener("pageshow", onPageShow);
return () => {
window.removeEventListener("pagehide", onPageHide);
window.removeEventListener("pageshow", onPageShow);
stopPush();
stopList();
stopAccounts();
if (refreshTimer) clearTimeout(refreshTimer);
if (listTimer) clearTimeout(listTimer);
channel?.close();
};
}, [available]);
return null;
}
+211
View File
@@ -0,0 +1,211 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { ProEmailView } from "@/components/pro/pro-email-tab-body";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store";
import type { Email } from "@/lib/jmap/types";
import type { ProComposeTabData } from "@/stores/pro-tab-store";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { announceMailChanged, openPopoutChannel } from "@/lib/popout/channel";
import { openComposerWindow } from "@/lib/popout/open";
import { closePopoutWindow } from "@/lib/popout/window-role";
import { PopoutNotice, PopoutShell, PopoutSkeleton } from "./popout-shell";
/** How often an open message checks that it is still there, without a main window to say so. */
const CHECK_INTERVAL_MS = 60_000;
/**
* The id from the address bar: a page the service worker kept for a start
* without the network was rendered for another id.
*/
function readLaunch(paramId: string): { emailId: string; accountId?: string; ownerAccountId?: string } {
if (typeof window === "undefined") return { emailId: paramId };
const segments = window.location.pathname.split("/").filter(Boolean);
const at = segments.lastIndexOf("message");
let emailId = paramId;
try {
if (at >= 0 && segments[at + 1]) emailId = decodeURIComponent(segments[at + 1]);
} catch {
// A malformed address: the route's own value.
}
const search = new URLSearchParams(window.location.search);
return { emailId, accountId: search.get("account") ?? undefined, ownerAccountId: search.get("owner") ?? undefined };
}
type Presence = "present" | "gone";
type LiveState = Pick<Email, "keywords" | "mailboxIds">;
/**
* Whether the message still exists, and in which folders. A JMAP move keeps
* the id, so moved mail stays readable here; only its folders change.
*/
function useMessagePresence(emailId: string, ownerAccountId: string | undefined): { presence: Presence; mailboxIds: string[] | null; liveState: LiveState | null } {
const client = useAuthStore((s) => s.client);
const [presence, setPresence] = useState<Presence>("present");
const [mailboxIds, setMailboxIds] = useState<string[] | null>(null);
const [liveState, setLiveState] = useState<LiveState | null>(null);
const checking = useRef(false);
useEffect(() => {
if (!client) return;
let cancelled = false;
const check = async () => {
if (checking.current) return;
checking.current = true;
try {
const [found] = await client.getSomeEmails([emailId], ownerAccountId);
if (cancelled) return;
setPresence(found ? "present" : "gone");
if (found?.mailboxIds) setMailboxIds(Object.keys(found.mailboxIds).sort());
if (found) {
setLiveState({ keywords: found.keywords, mailboxIds: found.mailboxIds });
// What the next star or read toggle starts from.
useEmailStore.setState((state) => ({
emails: state.emails.map((email) => (email.id === emailId ? { ...email, keywords: found.keywords, mailboxIds: found.mailboxIds } : email)),
}));
}
} catch {
// No answer says nothing about the message: it stays on screen.
} finally {
checking.current = false;
}
};
void check();
const channel = openPopoutChannel((message) => {
if (message.type === "state-change") void check();
});
const onFocus = () => void check();
window.addEventListener("focus", onFocus);
const timer = window.setInterval(() => {
if (document.visibilityState === "visible") void check();
}, CHECK_INTERVAL_MS);
return () => {
cancelled = true;
channel?.close();
window.removeEventListener("focus", onFocus);
window.clearInterval(timer);
};
}, [client, emailId, ownerAccountId]);
return { presence, mailboxIds, liveState };
}
function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string; accountId?: string; ownerAccountId?: string }) {
const t = useTranslations("pwaWindows");
const tSidebar = useTranslations("sidebar");
const login = useAuthStore((s) => accountId ?? s.activeAccountId ?? undefined);
const client = useAuthStore((s) => (login ? s.getClientForAccount(login) : undefined) ?? s.client);
const mailboxes = useEmailStore((s) => s.mailboxes);
const [subject, setSubject] = useState<string | null>(null);
const subjectInTitle = usePolicyStore((s) => s.loaded && s.isFeatureEnabled("tabTitleSubjectEnabled"));
useDocumentTitle(subjectInTitle ? subject : null);
// Folders first: without them Delete has no Trash to move to, Archive has no
// button yet, and the toolbar moves under the pointer once they arrive.
const [foldersReady, setFoldersReady] = useState(mailboxes.length > 0);
useEffect(() => {
if (!client) return;
let cancelled = false;
void Promise.resolve(useEmailStore.getState().fetchMailboxes(client)).catch(() => {}).finally(() => {
if (!cancelled) setFoldersReady(true);
});
return () => {
cancelled = true;
};
// Once per client: an account without folders shouldn't ask forever.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client]);
const { presence, mailboxIds, liveState } = useMessagePresence(emailId, ownerAccountId);
const firstMailboxIds = useRef<string | null>(null);
if (mailboxIds && firstMailboxIds.current === null) firstMailboxIds.current = mailboxIds.join();
const moved = !!mailboxIds && firstMailboxIds.current !== mailboxIds.join();
const movedTo = moved ? mailboxes.find((mailbox) => mailboxIds.includes(mailbox.originalId || mailbox.id)) : undefined;
const handleClose = useCallback(() => {
announceMailChanged(login ?? null);
closePopoutWindow();
}, [login]);
const handleMailChanged = useCallback(() => announceMailChanged(login ?? null), [login]);
const handleLoaded = useCallback((email: Email) => {
setSubject(email.subject || null);
// Delete, move, star and read look the message up in the list and do
// nothing when it isn't there, and this window has no list of its own.
useEmailStore.setState({ emails: [email], selectedMailbox: Object.keys(email.mailboxIds ?? {})[0] ?? "" });
}, []);
// Reply and forward open a composer window of their own.
const openCompose = useCallback(async (data: Omit<ProComposeTabData, "sessionId">) => {
const opened = openComposerWindow(login);
if (!opened) {
toast.error(t("popup_blocked"));
return false;
}
const shown = await opened.handOver({
mode: data.mode,
replyTo: data.replyTo,
initialDraftText: data.initialDraftText,
initialData: data.initialData,
prefill: data.prefill,
sourceEmailId: data.sourceEmailId,
sourceEmailSource: data.sourceEmailSource,
});
if (!shown) {
opened.cancel();
toast.error(t("window_not_ready"));
}
return shown;
}, [login, t]);
if (!foldersReady) return <PopoutSkeleton label={t("opening")} />;
if (presence === "gone") {
return (
<PopoutNotice title={t("message_gone_title")} body={t("message_gone_body")}>
<Button onClick={handleClose}>{t("close_window")}</Button>
</PopoutNotice>
);
}
return (
<>
{moved && (
<div role="status" data-popout-moved="" className="shrink-0 border-b border-border bg-muted px-4 py-2 text-sm text-foreground">
{movedTo
? t("message_moved_to", { folder: localizeMailboxName(movedTo.role, movedTo.name, (key) => tSidebar(`mailboxes.${key}`)) })
: t("message_moved")}
</div>
)}
<ProEmailView
emailId={emailId}
client={client ?? undefined}
clientAccountId={login}
accountId={ownerAccountId}
onLoaded={handleLoaded}
onClose={handleClose}
onOpenCompose={openCompose}
liveState={liveState}
onMailChanged={handleMailChanged}
className="min-h-0 w-full flex-1"
/>
</>
);
}
/** `/popout/message/<id>`: one message in its own window, with no rail, sidebar or list. */
export function PopoutMessageApp({ id }: { id: string }) {
const [launch] = useState(() => readLaunch(id));
return (
<PopoutShell kind="message" accountId={launch.accountId}>
<PopoutMessage emailId={launch.emailId} accountId={launch.accountId} ownerAccountId={launch.ownerAccountId} />
</PopoutShell>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config";
import { useAuthStore, SHARED_SESSION_ENDED_EVENT } from "@/stores/auth-store";
import { withBasePath } from "@/lib/browser-navigation";
import { flushComposers } from "@/lib/compose-flush-registry";
import { openPopoutChannel, requestWindowSession } from "@/lib/popout/channel";
import { closePopoutWindow } from "@/lib/popout/window-role";
import { onSignedOutElsewhere } from "@/lib/sign-out-cleanup";
/** How long a closing pop-out waits for its composer to save, when the account went away elsewhere. */
const CLOSE_FLUSH_MS = 3000;
/** How long it waits to hear that another window still holds the login. */
const STILL_HELD_MS = 800;
/** The session gate and page frame of a pop-out window. Takes the session from the main window (lib/popout). */
export function PopoutShell({ kind, accountId, children }: {
kind: "message" | "compose";
/** The login to open; left out, the main window's active one. */
accountId?: string;
children: React.ReactNode;
}) {
const t = useTranslations("pwaWindows");
// Fetches the organisation's policy, which device policies and send rules read.
useConfig();
const [status, setStatus] = useState<"loading" | "ready" | "no-session">("loading");
useEffect(() => {
let cancelled = false;
void useAuthStore.getState().startSharedSession({ accountId }).then((result) => {
if (!cancelled) setStatus(result);
});
return () => {
cancelled = true;
};
}, [accountId]);
// The session ended elsewhere: save what can be saved, and go. A composer
// whose text didn't make it stays, as the only place that text still is.
const endSession = useCallback(async () => {
const flushed = await flushComposers(CLOSE_FLUSH_MS);
if (kind === "compose" && (flushed.failed > 0 || flushed.timedOut)) setStatus("no-session");
else closePopoutWindow();
}, [kind]);
useEffect(() => {
const channel = openPopoutChannel((message) => {
const active = useAuthStore.getState().activeAccountId;
if (message.type !== "account-removed" || message.accountId !== active) return;
// One tab dropping a login it couldn't restore says nothing about the
// tab that is signed in with it.
void requestWindowSession({ accountId: active }, STILL_HELD_MS).then((held) => {
if (!held?.sessions.some((session) => session.accountId === active)) void endSession();
});
});
const stopSignOut = onSignedOutElsewhere(() => void endSession());
return () => {
channel?.close();
stopSignOut();
};
}, [endSession]);
// The session can't be renewed and no main window is left to do it. A
// message stops showing; a composer stays, its text still worth copying.
useEffect(() => {
const onEnded = () => setStatus("no-session");
window.addEventListener(SHARED_SESSION_ENDED_EVENT, onEnded);
return () => window.removeEventListener(SHARED_SESSION_ENDED_EVENT, onEnded);
}, []);
const actions = (
<>
<Button onClick={() => window.location.replace(withBasePath("/"))}>{t("open_app")}</Button>
<Button variant="ghost" onClick={closePopoutWindow}>{t("close_window")}</Button>
</>
);
return (
<div data-popout-window={kind} data-popout-status={status} className="flex h-dvh flex-col overflow-hidden bg-background pt-[var(--app-top-inset)]">
{status === "loading" && <PopoutSkeleton label={t("opening")} />}
{status === "no-session" && kind === "message" && (
<PopoutNotice title={t("session_ended_title")} body={t("session_ended_body")}>{actions}</PopoutNotice>
)}
{status === "no-session" && kind === "compose" && (
<div role="alert" data-popout-session-ended="" className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border bg-muted px-4 py-2 text-sm text-foreground">
<span className="min-w-0 flex-1">{t("session_ended_title")}. {t("session_ended_body")}</span>
{actions}
</div>
)}
{/* One place in the tree, so a composer stays mounted, text and all, when the session ends. */}
{(status === "ready" || (status === "no-session" && kind === "compose")) && children}
</div>
);
}
export function PopoutSkeleton({ label }: { label: string }) {
return (
<div role="status" aria-label={label} className="flex flex-1 flex-col gap-4 p-6" data-popout-skeleton="">
<div className="h-6 w-2/3 rounded bg-muted" />
<div className="h-4 w-1/3 rounded bg-muted" />
<div className="mt-4 h-4 w-full rounded bg-muted" />
<div className="h-4 w-5/6 rounded bg-muted" />
<div className="h-4 w-4/6 rounded bg-muted" />
</div>
);
}
export function PopoutNotice({ title, body, children }: { title: string; body: string; children?: React.ReactNode }) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center" data-popout-notice="">
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
<p className="max-w-md text-sm text-muted-foreground">{body}</p>
{children && <div className="mt-2 flex items-center gap-2">{children}</div>}
</div>
);
}
+10
View File
@@ -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;
}
@@ -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);
+21
View File
@@ -15,6 +15,8 @@ import { threadKeyFor } from "@/lib/thread-utils";
import { formatRejectedRecipients } from "@/lib/jmap/client";
import { announceSent } from "@/lib/plugin-after-send";
import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
import { usePopoutsAvailable } from "@/hooks/use-popouts-available";
import { openComposerWindow } from "@/lib/popout/open";
interface ProComposeTabBodyProps {
tabId: string;
@@ -212,6 +214,8 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const popoutsOn = usePopoutsAvailable();
// Mirrors the standard shell (see the same prop in `components/mail/mail-app.tsx`).
// Without it `mode === 'compose'` resolves `findComposeIdentityId(identities,
// undefined)` -> null and every new message in the Pro shell defaults to the
@@ -234,6 +238,22 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
);
});
const popOut = useCallback(() => {
const target = openComposerWindow(useAuthStore.getState().activeAccountId);
if (!target) return null;
return {
cancel: target.cancel,
handOver: (state: ComposerDraftData) => target.handOver({
mode: state.mode,
replyTo: state.replyTo,
initialData: state,
sourceEmailId: data.sourceEmailId,
sourceEmailSource: data.sourceEmailSource,
composeFromAccountEmail,
}),
};
}, [composeFromAccountEmail, data.sourceEmailId, data.sourceEmailSource]);
return (
<div className="flex h-full w-full flex-col bg-background">
<ErrorBoundary fallback={ComposerErrorFallback}>
@@ -254,6 +274,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
requestCloseRef={requestCloseRef}
onDiscardDraft={handleDiscardDraft}
onSaveState={handleSaveState}
popOut={popoutsOn ? popOut : undefined}
className="flex-1"
/>
</ErrorBoundary>
+33 -4
View File
@@ -32,6 +32,7 @@ import { takeTabReply, useNotificationTabReply } from '@/lib/pwa-notify/tab-repl
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope';
import { canDownload } from '@/lib/device-policy';
import { useOpenEmailInWindow } from '@/hooks/use-popouts-available';
import { isFilePreviewable } from '@/lib/file-preview';
import dynamic from 'next/dynamic';
@@ -66,6 +67,16 @@ interface ProEmailViewProps {
accountId?: string;
/** Fires once the full message is loaded (tab retitling, sidebar highlight). */
onLoaded?: (email: Email) => void;
/**
* Opens a reply, forward or draft somewhere else than a Pro tab: a pop-out
* window (components/popout) has no tabs. Resolves true once it is on screen
* there: a draft leaves this view only then.
*/
onOpenCompose?: (data: Omit<ProComposeTabData, 'sessionId'>) => Promise<boolean>;
/** Flags and folders as the server has them now, when the host tracks them (a pop-out). */
liveState?: Pick<Email, 'keywords' | 'mailboxIds'> | null;
/** This view changed the message's flags, for a host other windows should hear from. */
onMailChanged?: () => void;
/**
* The message left this view: deleted, archived, moved, or reopened in the
* composer. The host decides what that means (close the tab / clear the
@@ -121,7 +132,7 @@ function buildReplyContext(email: Email): ProReplyContext {
* Hosted by `ProEmailTabBody` (a dedicated email tab) and by the folder tab's
* reading pane.
*/
export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onClose, className }: ProEmailViewProps) {
export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, onClose, className }: ProEmailViewProps) {
const t = useTranslations();
const tNotifications = useTranslations('notifications');
const tRemove = useTranslations('email_list.confirm_remove');
@@ -176,6 +187,12 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
onOpened?: () => void,
) => {
const open = (prefill: ComposePrefill | null) => {
if (onOpenCompose) {
void onOpenCompose({ ...data, prefill }).then((shown) => {
if (shown) onOpened?.();
});
return;
}
composerSessionIdRef.current += 1;
openComposeTabNow({ ...data, sessionId: composerSessionIdRef.current, prefill });
onOpened?.();
@@ -192,7 +209,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
})).then((prefill) => {
if (prefill) open(Object.keys(prefill).length > 0 ? prefill : null);
});
}, [identities, openComposeTabNow, t]);
}, [identities, onOpenCompose, openComposeTabNow, t]);
// Held in a ref so a host passing an inline callback doesn't refetch the
// message on every render.
@@ -243,6 +260,13 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
};
}, [client, clientOverride, clientAccountId, emailId, accountId, fetchEmailContent]);
useEffect(() => {
if (!liveState) return;
setEmail((prev) => (prev && prev.id === emailId
? { ...prev, keywords: liveState.keywords ?? prev.keywords, mailboxIds: liveState.mailboxIds ?? prev.mailboxIds }
: prev));
}, [liveState, emailId]);
const currentMailboxRole = useMemo(() => {
if (!email) return undefined;
const mb = email.mailboxIds
@@ -368,6 +392,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
if (!client || !email) return;
try {
if (!(await toggleStar(client, email.id, emailKeyFor(email)))) return;
onMailChanged?.();
// Reflect locally - the viewer re-reads from email-store's selectedEmail
// shape only for the mail tab; here we update our local copy too.
setEmail((prev) => prev ? {
@@ -381,12 +406,13 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
console.error('Toggle star failed:', err);
toast.error(tNotifications('error_updating'));
}
}, [client, email, toggleStar, tNotifications]);
}, [client, email, toggleStar, tNotifications, onMailChanged]);
const handleMarkAsRead = useCallback(async (emailIdToMark: string, read: boolean, opts?: { automatic?: boolean }) => {
if (!client) return;
try {
if (!(await markAsRead(client, emailIdToMark, read))) return;
onMailChanged?.();
setEmail((prev) => prev && prev.id === emailIdToMark ? {
...prev,
keywords: { ...prev.keywords, $seen: read },
@@ -396,7 +422,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
// A refused mark-as-read on opening needs no notice (see EmailViewer).
if (!opts?.automatic) toast.error(tNotifications('error_updating'));
}
}, [client, markAsRead, tNotifications]);
}, [client, markAsRead, tNotifications, onMailChanged]);
const handleSetTag = useCallback((emailIdToTag: string, tagId: string | null) => {
if (!email || email.id !== emailIdToTag) return;
@@ -457,6 +483,8 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
return { blob, contentType: preview.type || blob.type };
}, [preview]);
const openInWindow = useOpenEmailInWindow();
const handleQuickReply = useCallback(async (body: string) => {
handleReply(body);
}, [handleReply]);
@@ -534,6 +562,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
onMarkAsRead={handleMarkAsRead}
onSetTag={handleSetTag}
onDownloadAttachment={handleDownloadAttachment}
onOpenInWindow={openInWindow && email ? () => openInWindow(email) : undefined}
onQuickReply={handleQuickReply}
onSaveQuickReplyDraft={handleSaveQuickReplyDraft}
onEditDraft={handleEditDraft}
+1 -1
View File
@@ -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>
);
@@ -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() {
@@ -8,6 +8,8 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfig } from "@/hooks/use-config";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { flushComposers } from "@/lib/compose-flush-registry";
import { isPopoutPath } from "@/lib/popout/window-role";
import { isStandaloneDisplayMode } from "@/lib/standalone-display";
import { getPathPrefix } from "@/lib/browser-navigation";
import { matchSurface, type AppSurface } from "@/lib/deep-links";
import {
@@ -45,7 +47,6 @@ type LaunchParams = {
targetURL?: string;
files?: ReadonlyArray<{ kind?: string; getFile?: () => Promise<File> }>;
};
type StandaloneNavigator = Navigator & { standalone?: boolean };
declare global {
interface Window {
@@ -91,11 +92,6 @@ const OFFER_FILE_COUNT = 5;
type ShareOffer = { subject: string; body: string; files: string[] };
function isStandaloneDisplayMode() {
return window.matchMedia?.("(display-mode: standalone)").matches
|| (navigator as StandaloneNavigator).standalone === true;
}
function openProtocolInNewTab(protocol: "mailto" | "webcal", raw: string): boolean {
const url = `${getPathPrefix()}/protocol/${protocol}?url=${encodeURIComponent(raw)}&fallback=1`;
const opened = window.open(url, "_blank");
@@ -206,7 +202,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
}, [pathname, leaveFor]);
useEffect(() => {
if (pathname.startsWith("/protocol/")) return;
if (pathname.startsWith("/protocol/") || isPopoutPath(pathname)) return;
return listenForMailtoRequests((pending) => {
savePendingMailto(pending);
@@ -221,7 +217,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
}, [pathname, router, t, appName]);
useEffect(() => {
if (typeof window === "undefined" || !window.launchQueue) return;
if (typeof window === "undefined" || !window.launchQueue || isPopoutPath(pathname)) return;
window.launchQueue.setConsumer((launchParams) => {
if (launchParams.files && launchParams.files.length > 0) {
@@ -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 {
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useMemo, useCallback, useId, useRef } from 'react';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import QRCode from 'qrcode';
@@ -1569,8 +1570,12 @@ export function AccountSecuritySettings() {
<SettingsSection id="devices" title={t('title')} description={t('description')}>
{!IS_LITE ? (
<div className="space-y-6">
<LinkDeviceSection />
<div className="border-t border-border" />
{!IS_DESKTOP_BUILD && (
<>
<LinkDeviceSection />
<div className="border-t border-border" />
</>
)}
<div className="text-sm text-muted-foreground" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t.raw('not_available')) }} />
</div>
) : (
@@ -1610,7 +1615,7 @@ export function AccountSecuritySettings() {
description={withoutPassword ? t('description') : t('sections.devices_description')}
>
<div className="space-y-6">
{!IS_LITE && (
{!IS_LITE && !IS_DESKTOP_BUILD && (
<>
<LinkDeviceSection />
<div className="border-t border-border" />
+3 -3
View File
@@ -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 && (
+2 -2
View File
@@ -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;
+4 -1
View File
@@ -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();
+53
View File
@@ -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;
}
+96 -4
View File
@@ -164,6 +164,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 () => {
@@ -242,6 +282,45 @@ test('with the browser offline, kept mail opens, also after a reload without a n
await page.context().close();
});
test('the files on the device hold no readable mail, and a store whose key went starts over', async ({ browser }) => {
const subject = `Offline encrypted ${RUN}`;
const secret = `Secret body ${RUN}`;
await deliverAndWait(subject, secret);
const page = await userPage(browser, ENT.alice);
await enableOffline(page);
await toMail(page);
await expect.poll(async () => (await offlineDatabase(page)).accounts, { timeout: 30_000 }).toBeGreaterThan(0);
await openMessage(page, subject);
await expect(body(page)).toContainText(secret, { timeout: 20_000 });
// Long enough for the store to have the message and its text on disk.
await page.waitForTimeout(5_000);
const file = await storeFileName(page);
const raw = await rawStoreFiles(page, [subject, secret, 'SQLite format 3', file]);
expect(raw.bytes).toBeGreaterThan(100_000);
// The name of the file is the one thing here in the clear: the search works.
expect(raw.found).toEqual([file]);
// The browser clears the key database and leaves the files.
await page.evaluate(() => new Promise<void>((resolve) => {
const request = indexedDB.deleteDatabase('bulwark-store');
request.onsuccess = () => resolve();
request.onerror = () => resolve();
request.onblocked = () => resolve();
}));
await page.goto(new URL('/', page.url()).href);
// Once on screen and once for screen readers.
await expect(page.locator('p', { hasText: "couldn't be read" }).first()).toBeVisible({ timeout: 60_000 });
await toMail(page);
await expect(page.locator('[data-testid="email-list-item"]', { hasText: subject }).first()).toBeVisible({ timeout: 60_000 });
await expect.poll(async () => (await offlineDatabase(page)).accounts, { timeout: 30_000 }).toBeGreaterThan(0);
const newFile = await storeFileName(page);
expect(newFile).not.toBe(file);
const after = await rawStoreFiles(page, [file, newFile]);
expect(after.found).toEqual([newFile]);
await page.context().close();
});
test('a mail written offline waits in the outbox, the required label still stops it, and it goes out labelled when back online', async ({ browser }) => {
const subject = `Outbox label ${RUN}`;
await setPolicy(browser, { organisation: { labels: { list: LABELS, defaultId: '', required: true, extraHeader: '' } } });
@@ -400,8 +479,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);
@@ -417,9 +496,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;
}
@@ -434,6 +516,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');
});
});
interface ServerDraft { id: string; subject: string; blobId: string; attachments: Array<{ blobId: string; name: string; size: number }> }
+675
View File
@@ -0,0 +1,675 @@
import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test';
import { ENT, adminContext, contextOptions, deliver, jmapAs, requireEntEnv, shot, withPolicy, ENT_PASSWORD } from './helpers/ent';
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
/**
* Windows of an installed app (design B, step 2) through the real UI on a
* VM 106 test Stalwart with the ent-* accounts:
* - the title bar next to the native window controls;
* - "Save all to a folder", with a stubbed showDirectoryPicker;
* - pop-out message and composer windows: session, draft handover, send,
* close with unsaved text, a message deleted or moved, state across windows.
*
* Headless Chromium has no installed-app mode, so the display mode and the
* overlay API are stubbed by an init script (which reaches popups too).
* window.open, the second window and everything behind it are real. What this
* can't show: a real install opening a popup inside the app rather than in
* the browser. A person has to check that once in Chrome and Edge.
*
* Rig:
* BUILD=1 bash start-prod.sh <worktree> <port> <jmap-url> pw-1.0
* bash run-spec.sh <worktree> <port> <jmap-url> 1.0 integration/tests/23-do-pwa-windows.spec.ts
*/
test.setTimeout(180_000);
const RUN = Date.now().toString(36);
/** A server check every second: tighter polling gets a test account rate limited. */
const POLL = [1000];
const STANDALONE = `(() => {
const real = window.matchMedia.bind(window);
window.matchMedia = (query) => query.includes('display-mode: standalone')
? { matches: true, media: query, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent() { return false; } }
: real(query);
})();`;
const OVERLAY = `(() => {
const overlay = new EventTarget();
overlay.visible = true;
overlay.getTitlebarAreaRect = () => ({ x: 0, y: 0, width: window.innerWidth - 140, height: 40 });
Object.defineProperty(navigator, 'windowControlsOverlay', { value: overlay });
})();`;
const BRIDGE = `window.bulwarkDesktop = { windows: {} };`;
async function appPage(browser: Browser, ...initScripts: string[]): Promise<Page> {
const context = await browser.newContext(contextOptions());
for (const script of initScripts) await context.addInitScript(script);
const page = await context.newPage();
await neutralizeDevOverlay(page);
await page.goto('/');
await page.fill('#username', ENT.alice);
await page.fill('#password', ENT_PASSWORD);
await page.click('button[type="submit"]');
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
return page;
}
/** A toast: the text is also in the live region for screen readers. */
function toast(page: Page, text: string | RegExp) {
return page.getByRole('region', { name: 'Notifications' }).getByText(text).first();
}
function row(page: Page, subject: string) {
return page.locator('[data-testid="email-list-item"]', { hasText: subject }).first();
}
async function openInMain(page: Page, subject: string): Promise<void> {
await expect(row(page, subject)).toBeVisible({ timeout: 60_000 });
await row(page, subject).click();
}
async function deliverToAlice(subject: string): Promise<string> {
await deliver({ from: ENT.bob, to: [ENT.alice], subject, text: `Body of ${subject}` });
const alice = await jmapAs(ENT.alice);
const found = await alice.waitForEmail(subject, { timeoutMs: 30_000 });
return found.id;
}
/** A message in Alice's inbox with two attachments of the same name and one other. */
async function messageWithAttachments(subject: string): Promise<void> {
const alice = await jmapAs(ENT.alice);
const inbox = await alice.mailboxByRole('inbox');
const res = await alice.request([['Email/set', {
accountId: alice.accountId,
create: { m: {
mailboxIds: { [inbox!.id]: true },
from: [{ email: ENT.bob }],
to: [{ email: ENT.alice }],
subject,
bodyValues: {
t: { value: 'See the files.' },
a: { value: 'first copy' },
b: { value: 'second copy' },
c: { value: 'notes' },
},
bodyStructure: {
type: 'multipart/mixed',
subParts: [
{ partId: 't', type: 'text/plain' },
{ partId: 'a', type: 'text/plain', name: 'same.txt', disposition: 'attachment' },
{ partId: 'b', type: 'text/plain', name: 'same.txt', disposition: 'attachment' },
{ partId: 'c', type: 'text/plain', name: 'notes.txt', disposition: 'attachment' },
],
},
} },
}, '0']]);
if (!res.methodResponses[0][1].created?.m) throw new Error(JSON.stringify(res.methodResponses[0][1]));
}
/** Stubs the picker on the page, with a folder that records what it is given. */
async function stubDirectoryPicker(page: Page, opts: { failOn?: string; cancel?: boolean } = {}): Promise<void> {
await page.evaluate(({ failOn, cancel }) => {
const files = new Map<string, string>();
(window as unknown as { __folderFiles: Map<string, string> }).__folderFiles = files;
(window as unknown as { __pickerCalls: number }).__pickerCalls = 0;
(window as unknown as { showDirectoryPicker: unknown }).showDirectoryPicker = async () => {
(window as unknown as { __pickerCalls: number }).__pickerCalls += 1;
if (cancel) throw new DOMException('closed', 'AbortError');
return {
name: 'Saved mail',
async getFileHandle(name: string, options?: { create?: boolean }) {
if (!files.has(name) && !options?.create) throw new DOMException('missing', 'NotFoundError');
return {
createWritable: async () => ({
write: async (data: Blob) => {
if (name === failOn) throw new DOMException('Disk full', 'QuotaExceededError');
files.set(name, await data.text());
},
close: async () => {},
abort: async () => {},
}),
};
},
async removeEntry(name: string) { files.delete(name); },
};
};
}, opts);
}
const folderFiles = (page: Page) => page.evaluate(() => [...(window as unknown as { __folderFiles: Map<string, string> }).__folderFiles.entries()]);
function watchAuthCalls(context: BrowserContext): string[] {
const calls: string[] = [];
context.on('request', (request) => {
const url = new URL(request.url());
if (request.method() !== 'PUT') return;
if (url.pathname === '/api/auth/token' || url.pathname === '/api/auth/session') {
calls.push(`${request.frame().page().url()} ${url.pathname}${url.search}`);
}
});
return calls;
}
async function openPopout(page: Page, trigger: () => Promise<void>): Promise<Page> {
const [popup] = await Promise.all([page.context().waitForEvent('page'), trigger()]);
await popup.waitForLoadState('domcontentloaded');
return popup;
}
async function popOutMessage(page: Page, subject: string): Promise<Page> {
await openInMain(page, subject);
const button = page.locator('[data-testid="viewer-open-in-window"]').first();
await expect(button).toBeVisible({ timeout: 30_000 });
const popup = await openPopout(page, () => button.click());
await expect(popup.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 });
return popup;
}
async function draftsCalled(subject: string): Promise<Array<{ id: string; subject: string }>> {
const alice = await jmapAs(ENT.alice);
const drafts = await alice.mailboxByRole('drafts');
const res = await alice.request([
['Email/query', { accountId: alice.accountId, filter: { inMailbox: drafts!.id, subject } }, '0'],
['Email/get', { accountId: alice.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'] }, '1'],
]);
return res.methodResponses[1][1].list;
}
test.beforeAll(() => requireEntEnv());
test.describe('the manifest and the title bar', () => {
test('the manifest asks for the overlay and falls back to standalone', async ({ request, baseURL }) => {
const res = await request.get(`${baseURL ?? process.env.IT_WEBMAIL_URL}/manifest.webmanifest`);
const manifest = await res.json();
expect(manifest.display).toBe('standalone');
expect(manifest.display_override).toEqual(['window-controls-overlay', 'standalone']);
});
test('without an overlay nothing changes', async ({ browser }) => {
const page = await appPage(browser);
await expect(page.locator('[data-window-title-bar]')).toHaveCount(0);
const padding = await page.evaluate(() => {
const root = document.querySelector('[class*="--app-top-inset"]') as HTMLElement;
return { padding: getComputedStyle(root).paddingTop, top: root.getBoundingClientRect().top };
});
expect(padding).toEqual({ padding: '0px', top: 0 });
await page.context().close();
});
test('with one, the app starts below a drag strip that leaves the controls free', async ({ browser }) => {
const page = await appPage(browser, OVERLAY);
const strip = page.locator('[data-window-title-bar]');
await expect(strip).toBeVisible();
const box = await strip.boundingBox();
expect(box).toMatchObject({ x: 0, y: 0, height: 40 });
expect(box!.width).toBe(1440 - 140);
const region = await strip.evaluate((el) => {
const style = getComputedStyle(el) as CSSStyleDeclaration & { appRegion?: string; webkitAppRegion?: string };
return style.appRegion ?? style.webkitAppRegion;
});
expect(region).toBe('drag');
// The window title, in small text.
await expect(strip).toContainText(/\S/);
const rail = await page.evaluate(() => {
const root = document.querySelector('[class*="--app-top-inset"]') as HTMLElement;
return { padding: getComputedStyle(root).paddingTop };
});
expect(rail.padding).toBe('40px');
await shot(page, 'pwa-windows-title-bar');
// A full-page dialog keeps clear of it too.
await page.context().close();
});
});
test.describe('save all attachments to a folder', () => {
const SUBJECT = `Files ${RUN}`;
test.beforeAll(() => messageWithAttachments(SUBJECT));
test('writes every file, numbers the same name, and says how many', async ({ browser }) => {
const page = await appPage(browser);
await stubDirectoryPicker(page);
await openInMain(page, SUBJECT);
await page.locator('[data-testid="attachments-save-to-folder"]').first().click();
await expect(toast(page, 'Saved 3 attachments to Saved mail.')).toBeVisible({ timeout: 30_000 });
const files = new Map(await folderFiles(page));
expect([...files.keys()].sort()).toEqual(['notes.txt', 'same (1).txt', 'same.txt']);
expect(files.get('notes.txt')).toContain('notes');
expect([files.get('same.txt'), files.get('same (1).txt')].map((text) => text!.trim()).sort()).toEqual(['first copy', 'second copy']);
await page.context().close();
});
test('a write failing midway says how many were saved', async ({ browser }) => {
const page = await appPage(browser);
await stubDirectoryPicker(page, { failOn: 'notes.txt' });
await openInMain(page, SUBJECT);
await page.locator('[data-testid="attachments-save-to-folder"]').first().click();
await expect(toast(page, 'Saved 2 of 3 attachments to Saved mail.')).toBeVisible({ timeout: 30_000 });
await page.context().close();
});
test('closing the picker does nothing', async ({ browser }) => {
const page = await appPage(browser);
await stubDirectoryPicker(page, { cancel: true });
await openInMain(page, SUBJECT);
await page.locator('[data-testid="attachments-save-to-folder"]').first().click();
await expect.poll(() => page.evaluate(() => (window as unknown as { __pickerCalls: number }).__pickerCalls)).toBe(1);
await expect(toast(page, /Saved \d/)).toHaveCount(0);
await page.context().close();
});
test('is gone when the organisation blocks downloads', async ({ browser }) => {
const admin = await adminContext(browser);
const page = await appPage(browser);
await stubDirectoryPicker(page);
await withPolicy(admin, { organisation: { devices: { noDownloads: true } } }, async () => {
await page.reload();
await stubDirectoryPicker(page);
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
await openInMain(page, SUBJECT);
await expect(page.getByTestId('attachments-downloads-off').first()).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid="attachments-save-to-folder"]')).toHaveCount(0);
});
await admin.close();
await page.context().close();
});
});
test.describe('pop-out windows', () => {
test('paints from the server HTML: its own frame, no spinner, no rail, the theme set before the first paint', async ({ browser }) => {
const context = await browser.newContext(contextOptions());
for (const path of ['/en/popout/compose', '/en/popout/message/x1']) {
const res = await context.request.get(path);
expect(res.status(), path).toBe(200);
const html = await res.text();
expect(html, path).toContain('data-popout-skeleton');
expect(html, path).toContain('data-popout-status="loading"');
expect(html, path).not.toContain('animate-spin');
expect(html, path).not.toContain('data-sk-rail');
expect(html, path).not.toContain('data-boot-skeleton=');
expect(html.indexOf('theme-storage'), path).toBeLessThan(html.indexOf('data-popout-window'));
}
await context.close();
});
test('a browser tab offers none of it', async ({ browser }) => {
const subject = `Tab ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser);
await openInMain(page, subject);
await expect(page.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid="viewer-open-in-window"]')).toHaveCount(0);
await page.keyboard.press('c');
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' });
await expect(page.locator('[data-testid="composer-open-in-window"]')).toHaveCount(0);
await page.context().close();
});
test('the bridge counts too: display-mode is `browser` in Electron', async ({ browser }) => {
const subject = `Bridge ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser, BRIDGE);
await openInMain(page, subject);
await expect(page.locator('[data-testid="viewer-open-in-window"]').first()).toBeVisible({ timeout: 30_000 });
await page.context().close();
});
test('a message opens alone, on the main window\'s session, with no rail and no token renewal', async ({ browser }) => {
const subject = `Popout ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser, STANDALONE);
const calls = watchAuthCalls(page.context());
const popup = await popOutMessage(page, subject);
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
await expect(popup.locator('nav')).toHaveCount(0);
expect(popup.url()).toContain('/popout/message/');
await expect(popup).toHaveTitle(new RegExp(subject));
expect(calls.filter((call) => call.includes('/popout/'))).toEqual([]);
await shot(popup, 'pwa-windows-popout-message');
await page.context().close();
});
test('opening the same message again focuses its window', async ({ browser }) => {
const subject = `Twice ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser, STANDALONE);
const popup = await popOutMessage(page, subject);
const opened: Page[] = [];
page.context().on('page', (p) => opened.push(p));
const url = popup.url();
let reloaded = false;
popup.on('framenavigated', () => { reloaded = true; });
await page.locator('[data-testid="viewer-open-in-window"]').first().click();
await popup.waitForTimeout(1000);
expect(opened).toEqual([]);
expect(reloaded).toBe(false);
expect(popup.url()).toBe(url);
await page.context().close();
});
test('a session with nothing to restore still works, and survives in the list', async ({ browser }) => {
const subject = `Forgotten ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser, STANDALONE);
await page.evaluate(() => {
const registry = JSON.parse(localStorage.getItem('account-registry')!);
for (const account of registry.state.accounts) account.rememberMe = false;
localStorage.setItem('account-registry', JSON.stringify(registry));
});
await page.context().clearCookies({ name: 'jmap_session' });
const popup = await popOutMessage(page, subject);
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
const accounts = await page.evaluate(() => JSON.parse(localStorage.getItem('account-registry')!).state.accounts.length);
expect(accounts).toBeGreaterThan(0);
await expect(accountSwitcher(page)).toBeVisible();
await page.context().close();
});
test('a reload with the main window gone restores from the cookies, without evicting', async ({ browser }) => {
const subject = `Alone ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser, STANDALONE);
const popup = await popOutMessage(page, subject);
const before = await popup.evaluate(() => localStorage.getItem('account-registry'));
await page.close();
await popup.reload();
await expect(popup.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 });
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
expect(await popup.evaluate(() => localStorage.getItem('account-registry'))).toBe(before);
await popup.context().close();
});
test('a message deleted or moved while open says so', async ({ browser }) => {
const subject = `Gone ${RUN}`;
const emailId = await deliverToAlice(subject);
const page = await appPage(browser, STANDALONE);
const popup = await popOutMessage(page, subject);
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
const alice = await jmapAs(ENT.alice);
const archive = await alice.mailboxByRole('archive');
if (archive) {
await alice.moveEmail(emailId, archive.id);
await popup.evaluate(() => window.dispatchEvent(new Event('focus')));
await expect(popup.locator('[data-popout-moved]')).toContainText('moved to', { timeout: 30_000 });
// A move keeps the id: the message stays readable.
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible();
}
await alice.request([['Email/set', { accountId: alice.accountId, destroy: [emailId] }, '0']]);
await popup.evaluate(() => window.dispatchEvent(new Event('focus')));
await expect(popup.getByText('This message is gone')).toBeVisible({ timeout: 30_000 });
await page.context().close();
});
test('deleting from the pop-out closes it', async ({ browser }) => {
const subject = `Delete ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser, STANDALONE);
const popup = await popOutMessage(page, subject);
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
const closed = popup.waitForEvent('close');
await popup.getByRole('button', { name: /^Delete$/ }).first().click();
await closed;
// The main window's list follows.
await expect(row(page, subject)).toHaveCount(0, { timeout: 30_000 });
const alice = await jmapAs(ENT.alice);
const trash = await alice.mailboxByRole('trash');
await expect.poll(async () => Object.keys((await alice.findEmailBySubject(subject))?.mailboxIds ?? {}), { intervals: POLL }).toEqual([trash!.id]);
});
test('state follows across windows without a reload', async ({ browser }) => {
const subject = `Sync ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser, STANDALONE);
const popup = await popOutMessage(page, subject);
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
let navigated = 0;
popup.on('framenavigated', () => { navigated += 1; });
// Theme.
await page.evaluate(() => {
const stored = JSON.parse(localStorage.getItem('theme-storage') ?? '{"state":{"theme":"light"},"version":1}');
stored.state.theme = 'dark';
localStorage.setItem('theme-storage', JSON.stringify(stored));
});
await expect(popup.locator('html')).toHaveClass(/dark/, { timeout: 10_000 });
// Language.
await page.evaluate(() => localStorage.setItem('locale-storage', JSON.stringify({ state: { locale: 'de' }, version: 0 })));
await expect(popup.locator('html')).toHaveAttribute('lang', 'de', { timeout: 15_000 });
await page.evaluate(() => localStorage.setItem('locale-storage', JSON.stringify({ state: { locale: 'en' }, version: 0 })));
await expect(popup.locator('html')).toHaveAttribute('lang', 'en', { timeout: 15_000 });
// Read state: from the pop-out to the main window's list. The main window
// looks at another message: the one it has open it would mark read again.
await expect(row(page, subject)).toHaveAttribute('data-unread', 'false', { timeout: 30_000 });
const other = `Sync other ${RUN}`;
await deliverToAlice(other);
await openInMain(page, other);
await popup.getByTitle('Mark as unread').first().click();
await expect(row(page, subject)).toHaveAttribute('data-unread', 'true', { timeout: 30_000 });
// And from the main window to the pop-out.
await row(page, subject).click();
await expect(row(page, subject)).toHaveAttribute('data-unread', 'false', { timeout: 30_000 });
await expect(popup.getByTitle('Mark as unread').first()).toBeVisible({ timeout: 30_000 });
expect(navigated).toBe(0);
await page.context().close();
});
});
test.describe('a composer in its own window', () => {
async function popOutComposer(page: Page, subject: string): Promise<Page> {
await page.keyboard.press('c');
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 });
const input = page.locator('[data-testid="composer-to"] input').first();
await input.click();
await input.fill(ENT.bob);
await input.press('Enter');
await page.locator('[data-testid="composer-subject"]').first().fill(subject);
await page.locator('[data-testid="email-composer"] [contenteditable="true"]').first().fill(`Body of ${subject}`);
const popup = await openPopout(page, () => page.locator('[data-testid="composer-open-in-window"]').first().click());
await expect(popup.locator('[data-popout-window="compose"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 });
await expect(popup.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject, { timeout: 30_000 });
return popup;
}
test('the draft moves over under one draft id, and the window closes after sending', async ({ browser }) => {
const subject = `Moved ${RUN}`;
const page = await appPage(browser, STANDALONE);
const popup = await popOutComposer(page, subject);
// Gone from the main window, with no "continue draft" left behind.
await expect(page.locator('[data-testid="email-composer"]')).toHaveCount(0);
await expect(popup.locator('[data-testid="composer-to"]')).toContainText(ENT.bob);
await expect(popup.locator('[data-testid="email-composer"] [contenteditable="true"]').first()).toContainText(`Body of ${subject}`);
await expect(popup.locator('nav')).toHaveCount(0);
await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
const [first] = await draftsCalled(subject);
await shot(popup, 'pwa-windows-popout-composer');
// Further edits replace that draft, they don't start another.
await popup.locator('[data-testid="composer-subject"]').first().fill(`${subject} edited`);
await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
await expect.poll(async () => (await draftsCalled(subject)).map((draft) => draft.id).includes(first.id), { timeout: 15_000, intervals: POLL }).toBe(false);
const closed = popup.waitForEvent('close');
await popup.locator('[data-testid="composer-send"]').first().click();
await closed;
const bob = await jmapAs(ENT.bob);
await bob.waitForEmail(`${subject} edited`, { timeoutMs: 60_000 });
await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 20_000, intervals: POLL }).toBe(0);
// The main window says it went, since the pop-out can't any more.
await expect(toast(page, 'Email sent')).toBeVisible({ timeout: 30_000 });
await page.context().close();
});
test('closing the window with unsaved text saves the draft', async ({ browser }) => {
const subject = `Unsaved ${RUN}`;
const page = await appPage(browser, STANDALONE);
const popup = await popOutComposer(page, subject);
await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
const typed = `${subject} typed`;
await popup.locator('[data-testid="composer-subject"]').first().fill(typed);
// Inside the 2 s of the autosave, so only the close can save it. No prompt: the main window takes it.
let prompted = false;
popup.on('dialog', (dialog) => { prompted = true; void dialog.accept(); });
const closed = popup.waitForEvent('close');
await popup.close({ runBeforeUnload: true });
await closed;
expect(prompted).toBe(false);
await expect.poll(async () => (await draftsCalled(typed)).length, { timeout: 20_000, intervals: POLL }).toBe(1);
// It replaced the version the window started from.
await expect.poll(async () => (await draftsCalled(subject)).filter((draft) => draft.subject === subject).length, { timeout: 20_000, intervals: POLL }).toBe(0);
await page.context().close();
});
test('with no main window left it asks, and staying lets the save finish', async ({ browser }) => {
const subject = `Asked ${RUN}`;
const page = await appPage(browser, STANDALONE);
const popup = await popOutComposer(page, subject);
await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
await page.close();
const typed = `${subject} typed`;
await popup.locator('[data-testid="composer-subject"]').first().fill(typed);
const asked = new Promise<void>((resolve) => {
popup.once('dialog', (dialog) => {
expect(dialog.type()).toBe('beforeunload');
void dialog.dismiss();
resolve();
});
});
await popup.close({ runBeforeUnload: true });
await asked;
expect(popup.isClosed()).toBe(false);
await expect.poll(async () => (await draftsCalled(typed)).length, { timeout: 20_000, intervals: POLL }).toBe(1);
});
test('a blocked window leaves the composer where it is', async ({ browser }) => {
const subject = `Blocked ${RUN}`;
const page = await appPage(browser, STANDALONE);
await page.evaluate(() => { window.open = () => null; });
await page.keyboard.press('c');
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 });
await page.locator('[data-testid="composer-subject"]').first().fill(subject);
await page.locator('[data-testid="composer-open-in-window"]').first().click();
await expect(toast(page, /blocked the new window/)).toBeVisible();
await expect(page.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject);
await page.context().close();
});
});
test.describe('what a window must not lose', () => {
test('text whose save fails in the main window lands in its "Continue draft" slot', async ({ browser }) => {
const subject = `Refused ${RUN}`;
const page = await appPage(browser, STANDALONE);
await page.keyboard.press('c');
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 });
await page.locator('[data-testid="composer-subject"]').first().fill(subject);
const popup = await openPopout(page, () => page.locator('[data-testid="composer-open-in-window"]').first().click());
await expect(popup.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject, { timeout: 60_000 });
await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
// Give the pop-out time to see the main window answer its ping.
await popup.waitForTimeout(500);
const typed = `${subject} typed`;
await popup.locator('[data-testid="composer-subject"]').first().fill(typed);
// The server stops taking saves, for the main window's rescue too.
await page.context().route('**/jmap/**', async (route) => {
if ((route.request().postData() ?? '').includes('Email/set')) await route.abort();
else await route.continue();
});
let prompted = false;
popup.on('dialog', (dialog) => { prompted = true; void dialog.accept(); });
const closed = popup.waitForEvent('close');
await popup.close({ runBeforeUnload: true });
await closed;
expect(prompted).toBe(false);
await expect(toast(page, /save/i)).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('Continue draft').first()).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(typed).first()).toBeVisible();
await page.context().unroute('**/jmap/**');
await page.context().close();
});
test('editing a draft from a message window opens a live composer, and only then closes', async ({ browser }) => {
const subject = `Edit ${RUN}`;
const alice = await jmapAs(ENT.alice);
const draftId = await alice.createDraft(subject, ENT.bob);
const page = await appPage(browser, STANDALONE);
const drafts = await alice.mailboxByRole('drafts');
await page.goto(`/mail/folder/${drafts!.id}`);
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
await expect(row(page, subject)).toBeVisible({ timeout: 60_000 });
const message = await openPopout(page, async () => {
await row(page, subject).click({ button: 'right' });
await page.getByTestId('ctx-open-window').click();
});
await expect(message.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 });
const closed = message.waitForEvent('close');
const composer = await openPopout(message, async () => {
await message.getByRole('button', { name: /^Edit/ }).first().click();
});
await expect(composer.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject, { timeout: 60_000 });
await closed;
// Live: it still saves over the same draft, with the window that opened it gone.
await composer.locator('[data-testid="composer-subject"]').first().fill(`${subject} edited`);
await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 20_000, intervals: POLL }).toBe(1);
await expect.poll(async () => (await draftsCalled(subject)).filter((draft) => draft.id === draftId).length, { timeout: 20_000, intervals: POLL }).toBe(0);
await page.context().close();
});
});
test.describe('the attachment bar', () => {
test('stays one row in a narrow pane, with both buttons in reach', async ({ browser }) => {
const subject = `Narrow ${RUN}`;
await messageWithAttachments(subject);
const page = await appPage(browser);
await stubDirectoryPicker(page);
await openInMain(page, subject);
const save = page.locator('[data-testid="attachments-save-to-folder"]').first();
await expect(save).toBeVisible({ timeout: 30_000 });
await page.setViewportSize({ width: 900, height: 900 });
const pane = page.locator('[data-testid="reader-attachments"]').first();
await expect(pane).toBeVisible();
const row = save.locator('xpath=..');
const rowBox = await row.boundingBox();
const saveBox = await save.boundingBox();
expect(rowBox!.height).toBeLessThan(48);
expect(saveBox!.x + saveBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width + 1);
await expect(save).toHaveAttribute('aria-label', /folder/i);
await page.context().close();
});
});
test.describe('the desktop app\'s behaviour', () => {
test('plain-text mail can be selected, list rows can\'t', async ({ browser }) => {
const subject = `Selectable ${RUN}`;
await deliverToAlice(subject);
const page = await appPage(browser, BRIDGE);
await openInMain(page, subject);
const body = page.locator('[data-plugin-source="email-body"]').first();
await expect(body).toBeVisible({ timeout: 30_000 });
expect(await body.evaluate((el) => getComputedStyle(el).userSelect)).toBe('text');
expect(await row(page, subject).evaluate((el) => getComputedStyle(el).userSelect)).toBe('none');
// A real selection, the way a user would make one.
await body.click({ clickCount: 3 });
expect(await page.evaluate(() => window.getSelection()?.toString() ?? '')).toContain(`Body of ${subject}`);
await page.context().close();
});
test('hangs on the bridge: a browser gets none of it', async ({ browser }) => {
const plain = await appPage(browser);
expect(await plain.locator('html').getAttribute('data-desktop')).toBeNull();
const button = plain.locator('button').first();
expect(await button.evaluate((el) => getComputedStyle(el).userSelect)).not.toBe('none');
await plain.context().close();
const app = await appPage(browser, BRIDGE);
expect(await app.locator('html').getAttribute('data-desktop')).toBe('');
const chrome = app.locator('button').first();
expect(await chrome.evaluate((el) => getComputedStyle(el).userSelect)).toBe('none');
expect(await app.evaluate(() => getComputedStyle(document.documentElement).overscrollBehaviorY)).toBe('none');
await app.context().close();
});
});
+158
View File
@@ -0,0 +1,158 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { desktopBootScript } from '@/lib/desktop/boot';
import { startWindowSync } from '@/lib/desktop/window-sync';
import { useLocaleStore } from '@/stores/locale-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useThemeStore } from '@/stores/theme-store';
/** What another window of this browser writing the store looks like here. */
function writeFromAnotherWindow(key: string, state: Record<string, unknown>) {
const current = JSON.parse(localStorage.getItem(key) ?? '{"state":{},"version":0}');
const value = JSON.stringify({ ...current, state: { ...current.state, ...state } });
localStorage.setItem(key, value);
window.dispatchEvent(new StorageEvent('storage', { key, newValue: value }));
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
describe('state across windows', () => {
let stop: () => void = () => {};
beforeEach(() => {
useThemeStore.setState({ theme: 'light' });
useLocaleStore.setState({ locale: 'en' });
});
afterEach(() => {
stop();
stop = () => {};
delete window.bulwarkDesktop;
window.history.replaceState(null, '', '/');
localStorage.clear();
document.documentElement.classList.remove('dark', 'light');
});
function asPopout() {
window.history.replaceState(null, '', '/en/popout/compose');
stop = startWindowSync();
}
it('follows a theme change without a reload, down to the page', async () => {
asPopout();
writeFromAnotherWindow('theme-storage', { theme: 'dark' });
await settle();
expect(useThemeStore.getState().theme).toBe('dark');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
it('follows a theme pack change too, which rehydrating alone does not put on the page', async () => {
asPopout();
const pack = useThemeStore.getState().installedThemes.find((theme) => theme.css);
expect(pack).toBeTruthy();
writeFromAnotherWindow('theme-storage', { theme: 'light', activeThemeId: pack!.id });
await settle();
expect(document.querySelector('style[id*="theme"]')?.textContent).toContain(pack!.css);
});
it('follows a language change without a reload', async () => {
asPopout();
writeFromAnotherWindow('locale-storage', { locale: 'de' });
await settle();
expect(useLocaleStore.getState().locale).toBe('de');
});
it('follows a settings change without a reload', async () => {
asPopout();
const before = useSettingsStore.getState().plainTextMode;
writeFromAnotherWindow('settings-storage', { plainTextMode: !before });
await settle();
expect(useSettingsStore.getState().plainTextMode).toBe(!before);
});
it('also runs in the desktop app\'s main window', async () => {
window.bulwarkDesktop = { version: 1, platform: 'win32', windows: {} };
stop = startWindowSync();
writeFromAnotherWindow('locale-storage', { locale: 'fr' });
await settle();
expect(useLocaleStore.getState().locale).toBe('fr');
});
it('leaves a browser tab as it was', async () => {
stop = startWindowSync();
writeFromAnotherWindow('locale-storage', { locale: 'fr' });
await settle();
expect(useLocaleStore.getState().locale).toBe('en');
});
});
describe('the app-like behaviour', () => {
const css = readFileSync(join(process.cwd(), 'app/desktop.css'), 'utf8');
const rules = css.replace(/\/\*[\s\S]*?\*\//g, '').match(/[^{}]+\{[^}]*\}/g) ?? [];
it('hangs entirely on html[data-desktop], so browsers do not change', () => {
expect(rules.length).toBeGreaterThan(0);
for (const rule of rules) {
const selector = rule.slice(0, rule.indexOf('{'));
// Every selector of the list; commas inside :is() don't split it.
const parts = selector.split(/,(?![^(]*\))/);
for (const part of parts) expect(part.trim(), part).toMatch(/^html\[data-desktop\]/);
}
});
it('covers UI chrome and what the spec names', () => {
expect(css).toMatch(/user-select: none/);
expect(css).toMatch(/cursor: default/);
expect(css).toMatch(/overscroll-behavior: none/);
expect(css).toMatch(/-webkit-user-drag: none/);
});
it('keeps fields selectable and what the app drags draggable', () => {
expect(css).toMatch(/:is\(input, textarea, select, \[contenteditable="true"\][^{]*\{\s*-webkit-user-select: text;\s*user-select: text;/);
expect(css).toMatch(/\[draggable="true"\]\s*\{\s*-webkit-user-drag: element;/);
});
it('does not switch selection off for the whole page, so message content stays selectable', () => {
expect(css).not.toMatch(/html\[data-desktop\]( body)?\s*\{[^}]*user-select: none/);
});
it('matches list rows only, not the plain-text body that carries the same data attribute', () => {
// email-viewer.tsx and thread-conversation-view.tsx put data-email-id on the body.
for (const file of ['components/email/email-viewer.tsx', 'components/email/thread-conversation-view.tsx']) {
const source = readFileSync(join(process.cwd(), file), 'utf8').replace(/\r\n/g, '\n');
expect(source, file).toMatch(/data-plugin-source="email-body"\n\s*data-email-id=/);
}
expect(css).not.toContain('data-email-id');
});
it('keeps a plain-text body selectable wherever it sits', () => {
expect(css).toMatch(/\[data-plugin-source="email-body"\],\s*html\[data-desktop\] \[data-plugin-source="email-body"\] \*\s*\{\s*-webkit-user-select: text;\s*user-select: text;/);
});
function runBoot() {
new Function(desktopBootScript())();
}
afterEach(() => {
delete window.bulwarkDesktop;
document.documentElement.removeAttribute('data-desktop');
});
it('marks the page before the first paint when the bridge is there', () => {
window.bulwarkDesktop = { version: 1, platform: 'win32' };
runBoot();
expect(document.documentElement.hasAttribute('data-desktop')).toBe(true);
});
it('does not mark a browser', () => {
runBoot();
expect(document.documentElement.hasAttribute('data-desktop')).toBe(false);
});
it('is in the root layout\'s boot script', () => {
const layout = readFileSync(join(process.cwd(), 'app/(main)/layout.tsx'), 'utf8');
expect(layout).toContain('${desktopBootScript()}');
expect(layout).toContain('../desktop.css');
});
});
@@ -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);
}
}
+3 -1
View File
@@ -42,6 +42,7 @@ import {
inlineScriptHashes,
installStalwartFetchShim,
isReservedStalwartSegment,
LITE_REMOVED_PATHS,
listZipEntries,
makeBuildId,
normalizeBasePath,
@@ -59,7 +60,6 @@ import {
stalwartRewriteIndex,
stalwartZipEntryProblems,
unexpectedEngineFiles,
LITE_REMOVED_PATHS,
unexpectedApiStrings,
withScriptHashCsp,
} from '../../scripts/lite/lib.mjs';
@@ -381,6 +381,8 @@ describe('prepare / postbuild / verify against a fake checkout', () => {
expect(plan).toContain('proxy.ts');
expect(plan).toContain('app/api');
expect(plan).toContain('app/(main)/admin');
// A static export can't list every message id; Lite doesn't offer pop-outs.
expect(LITE_REMOVED_PATHS).toContain('app/(main)/[locale]/popout');
expect(plan).not.toContain('app/(main)/[locale]/mail/[[...segments]]/page.tsx');
expect(plan).not.toContain('scripts/lite/smoke/lite.spec.ts');
} finally {
+15
View File
@@ -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'));
+351
View File
@@ -0,0 +1,351 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeBroadcastChannel } from './fixtures/fake-broadcast-channel';
import { isPopoutPath, mainWindowStorage } from '@/lib/popout/window-role';
// Pop-out windows: which paths are one, when they are on offer, how they open,
// and how a draft moves to one.
function stubDisplay({ standalone = true, fine = true }: { standalone?: boolean; fine?: boolean } = {}) {
vi.stubGlobal('matchMedia', (query: string) => ({
matches: query.includes('pointer: fine') ? fine : query.includes('display-mode: standalone') ? standalone : false,
addEventListener() {},
removeEventListener() {},
}));
window.matchMedia = globalThis.matchMedia;
}
beforeEach(() => {
FakeBroadcastChannel.reset();
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
Object.defineProperty(navigator, 'locks', { value: { request: vi.fn() }, configurable: true });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
delete window.bulwarkDesktop;
Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true });
window.history.replaceState(null, '', '/');
});
describe('isPopoutPath', () => {
it('knows the two routes, with or without locale and mount prefix', () => {
for (const path of ['/popout/compose', '/en/popout/compose', '/zh-TW/popout/message/abc', '/webmail/de/popout/message/abc', '/popout/compose/']) {
expect(isPopoutPath(path), path).toBe(true);
}
});
it('does not take other pages for one', () => {
// A folder in Files can have any name, and a mail folder too.
for (const path of ['/', '/mail', '/files/popout/compose', '/en/mail/folder/popout', '/popout', '/popout/message', '/popout/message/a/b', '/en/files/popout/message/x', '/files/en/popout/compose', '/some/folder/popout/compose']) {
expect(isPopoutPath(path), path).toBe(false);
}
});
});
describe('isPopoutPath under a mount named like a page (NEXT_PUBLIC_BASE_PATH)', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
it('takes the mount off first', async () => {
vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/mail');
vi.resetModules();
const { isPopoutPath: underMail } = await import('@/lib/popout/window-role');
expect(underMail('/mail/popout/compose')).toBe(true);
expect(underMail('/mail/de/popout/message/abc')).toBe(true);
// Under that mount, its files page stays a files page.
expect(underMail('/mail/files/popout/compose')).toBe(false);
});
});
describe('mainWindowStorage', () => {
it('lets a main window write, and a pop-out only read', () => {
localStorage.setItem('account-registry', 'before');
const storage = mainWindowStorage();
storage.setItem('account-registry', 'main');
expect(localStorage.getItem('account-registry')).toBe('main');
window.history.replaceState(null, '', '/en/popout/compose');
expect(storage.getItem('account-registry')).toBe('main');
storage.setItem('account-registry', 'popout');
storage.removeItem('account-registry');
expect(localStorage.getItem('account-registry')).toBe('main');
});
});
describe('popoutsAvailable', () => {
async function available() {
const { popoutsAvailable } = await import('@/lib/popout/support');
return popoutsAvailable();
}
it('is off in a browser tab: the app behaves as it always has', async () => {
stubDisplay({ standalone: false });
expect(await available()).toBe(false);
});
it('is on in an installed app on a device with a pointer', async () => {
stubDisplay();
expect(await available()).toBe(true);
});
it('is off in an installed app on a touch device, where window.open leaves the app', async () => {
stubDisplay({ fine: false });
expect(await available()).toBe(false);
});
it('asks the bridge too: display-mode is `browser` in Electron', async () => {
stubDisplay({ standalone: false });
window.bulwarkDesktop = { version: 1, platform: 'win32', windows: {} };
expect(await available()).toBe(true);
});
it('is off without Web Locks or BroadcastChannel, which keep the session safe across windows', async () => {
stubDisplay();
Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true });
expect(await available()).toBe(false);
Object.defineProperty(navigator, 'locks', { value: { request: vi.fn() }, configurable: true });
vi.stubGlobal('BroadcastChannel', undefined);
expect(await available()).toBe(false);
});
it('is off where sessions can\'t be sealed: a page not in a secure context has no crypto.subtle', async () => {
stubDisplay();
expect(await available()).toBe(true);
vi.stubGlobal('crypto', {});
expect(await available()).toBe(false);
});
it('is off in Lite, which has no such route', async () => {
vi.resetModules();
vi.doMock('@/lib/lite', async (original) => ({ ...await original<object>(), IS_LITE: true }));
stubDisplay();
const { popoutsAvailable } = await import('@/lib/popout/support');
expect(popoutsAvailable()).toBe(false);
vi.doUnmock('@/lib/lite');
vi.resetModules();
});
});
describe('opening windows', () => {
it('opens a message as a popup in the app, with its login and account', async () => {
const { openMessageWindow } = await import('@/lib/popout/open');
const open = vi.spyOn(window, 'open').mockReturnValue({ focus: vi.fn(), closed: false } as unknown as Window);
expect(openMessageWindow({ emailId: 'm/1', localAccountId: 'login-b', ownerAccountId: 'group' })).toBe(true);
const [url, name, features] = open.mock.calls[0];
expect(url).toBe('/popout/message/m%2F1?account=login-b&owner=group');
expect(name).toBe('bulwark-message-m_1');
expect(features).toMatch(/^popup=yes,width=\d+,height=\d+,left=\d+,top=\d+$/);
// In an installed app noopener sends the window to the system browser.
expect(features).not.toContain('noopener');
});
it('focuses the window a message already has, without loading it again', async () => {
const { openMessageWindow } = await import('@/lib/popout/open');
const focus = vi.fn();
const open = vi.spyOn(window, 'open').mockReturnValue({ focus, closed: false } as unknown as Window);
openMessageWindow({ emailId: 'twice' });
openMessageWindow({ emailId: 'twice' });
expect(open).toHaveBeenCalledTimes(1);
expect(focus).toHaveBeenCalledTimes(2);
});
it('opens it again once that window was closed', async () => {
const { openMessageWindow } = await import('@/lib/popout/open');
const first = { focus: vi.fn(), closed: false };
const open = vi.spyOn(window, 'open').mockReturnValue(first as unknown as Window);
openMessageWindow({ emailId: 'reopened' });
first.closed = true;
openMessageWindow({ emailId: 'reopened' });
expect(open).toHaveBeenCalledTimes(2);
});
it('leaves it to the bridge when the bridge opens windows, under the same name', async () => {
const { openMessageWindow } = await import('@/lib/popout/open');
const bridgeOpen = vi.fn();
window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { open: bridgeOpen } };
const open = vi.spyOn(window, 'open');
expect(openMessageWindow({ emailId: 'bridged' })).toBe(true);
expect(openMessageWindow({ emailId: 'bridged' })).toBe(true);
expect(open).not.toHaveBeenCalled();
expect(bridgeOpen.mock.calls[0][1]).toMatchObject({ name: 'bulwark-message-bridged' });
expect(bridgeOpen.mock.calls[1][1].name).toBe(bridgeOpen.mock.calls[0][1].name);
});
it('says false when a blocker stops the window', async () => {
const { openComposerWindow, openMessageWindow } = await import('@/lib/popout/open');
vi.spyOn(window, 'open').mockReturnValue(null);
expect(openMessageWindow({ emailId: 'blocked' })).toBe(false);
expect(openComposerWindow()).toBeNull();
});
});
describe('moving a draft to a pop-out', () => {
const payload = { mode: 'reply' as const, initialData: { draftId: 'saved-3', subject: 'Hi' } as never };
const settle = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms));
// The module keeps its channel while a handoff is pending.
beforeEach(() => vi.resetModules());
it('hands the draft to the window that asks, once it is ready, under the same draft id', async () => {
const { createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
const handoff = createComposeHandoff()!;
const received = receiveComposeHandoff(handoff.id);
await settle();
// The pop-out is open and waiting; the save hasn't finished.
let done = false;
void received.then(() => { done = true; });
await settle();
expect(done).toBe(false);
void handoff.complete(payload);
await expect(received).resolves.toMatchObject({ mode: 'reply', initialData: { draftId: 'saved-3' } });
});
it('lets the sender go only once the composer is on screen there', async () => {
const { acknowledgeComposeHandoff, createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
const handoff = createComposeHandoff()!;
void receiveComposeHandoff(handoff.id);
await settle();
let shown: boolean | undefined;
void handoff.complete(payload).then((result) => { shown = result; });
await settle();
expect(shown).toBeUndefined();
acknowledgeComposeHandoff(handoff.id);
await settle();
expect(shown).toBe(true);
});
it('tells the sender when the composer never shows, and it can still call it off', async () => {
const { createComposeHandoff, receiveComposeHandoff, watchHandoffCancel } = await import('@/lib/popout/compose-handoff');
const handoff = createComposeHandoff()!;
void receiveComposeHandoff(handoff.id);
await settle();
const cancelled = vi.fn();
const stop = watchHandoffCancel(handoff.id, cancelled);
await expect(handoff.complete(payload, 20)).resolves.toBe(false);
handoff.cancel();
await settle();
expect(cancelled).toHaveBeenCalledTimes(1);
stop();
});
it('keeps delivering when the message window that opened it is still there', async () => {
const { createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
const handoff = createComposeHandoff()!;
// The draft is ready before the new window asks for it.
void handoff.complete(payload);
await settle();
await expect(receiveComposeHandoff(handoff.id)).resolves.toMatchObject({ mode: 'reply' });
});
it('tells the pop-out when the move is called off', async () => {
const { createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
const handoff = createComposeHandoff()!;
const received = receiveComposeHandoff(handoff.id);
await settle();
handoff.cancel();
await expect(received).resolves.toBeNull();
});
it('gives up when nothing comes in time', async () => {
const { receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
await expect(receiveComposeHandoff('nobody-has-this', 10)).resolves.toBeNull();
});
it('starts a reload of the pop-out from what it holds, with no opener needed', async () => {
const { rememberComposePayload, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
rememberComposePayload('kept', payload);
FakeBroadcastChannel.reset();
vi.stubGlobal('BroadcastChannel', undefined);
await expect(receiveComposeHandoff('kept')).resolves.toMatchObject({ initialData: { draftId: 'saved-3' } });
});
});
describe('asking a main window for the session', () => {
it('takes the first answer and goes on without one after the timeout', async () => {
const { openPopoutChannel, requestWindowSession } = await import('@/lib/popout/channel');
const { sealFor } = await import('@/lib/popout/sealed');
await expect(requestWindowSession({}, 10)).resolves.toBeNull();
const main = openPopoutChannel((message) => {
if (message.type === 'session-request') {
void sealFor(message.publicKey, { activeAccountId: 'a', sessions: [] })
.then((sealed) => main?.post({ type: 'session-answer', requestId: message.requestId, sealed }));
}
});
await expect(requestWindowSession({}, 1000)).resolves.toEqual({ activeAccountId: 'a', sessions: [] });
main?.close();
});
it('puts no credential on the channel: a listener hears neither the header nor the refused one', async () => {
const { openPopoutChannel, requestWindowSession } = await import('@/lib/popout/channel');
const { sealFor } = await import('@/lib/popout/sealed');
const secret = 'Bearer very-secret-token';
const refused = 'Bearer refused-secret-token';
const heard: unknown[] = [];
const eavesdropper = openPopoutChannel((message) => heard.push(message));
const main = openPopoutChannel((message) => {
if (message.type !== 'session-request') return;
void sealFor(message.publicKey, { activeAccountId: 'a', sessions: [{ accountId: 'a', authHeader: secret }] })
.then((sealed) => main?.post({ type: 'session-answer', requestId: message.requestId, sealed }));
});
const answer = await requestWindowSession({ accountId: 'a', rejectedAuthHeader: refused }, 1000);
expect(answer?.sessions[0].authHeader).toBe(secret);
expect(heard.length).toBeGreaterThanOrEqual(2);
expect(JSON.stringify(heard)).not.toContain('secret');
main?.close();
eavesdropper?.close();
});
it('a listener can\'t open an answer meant for another window', async () => {
const { createSessionOpener, sealFor } = await import('@/lib/popout/sealed');
const asker = await createSessionOpener();
const other = await createSessionOpener();
const sealed = await sealFor(asker.publicKey, { secret: true });
await expect(asker.open(sealed)).resolves.toEqual({ secret: true });
await expect(other.open(sealed)).rejects.toThrow();
});
});
describe('text a closing pop-out could not save', () => {
it('waits for the mail view, marked unsaved, oldest first', async () => {
vi.resetModules();
sessionStorage.clear();
const { keepRescuedDraft, onRescuedDraft, takeRescuedDraft } = await import('@/lib/popout/rescued-drafts');
const told = vi.fn();
const stop = onRescuedDraft(told);
keepRescuedDraft({ subject: 'First' } as never);
keepRescuedDraft({ subject: 'Second' } as never);
stop();
expect(told).toHaveBeenCalledTimes(2);
expect(takeRescuedDraft()).toMatchObject({ subject: 'First', unsaved: true });
expect(takeRescuedDraft()).toMatchObject({ subject: 'Second' });
expect(takeRescuedDraft()).toBeNull();
});
it('survives a reload of the main window', async () => {
vi.resetModules();
sessionStorage.clear();
const first = await import('@/lib/popout/rescued-drafts');
first.keepRescuedDraft({ subject: 'Kept' } as never);
vi.resetModules();
const second = await import('@/lib/popout/rescued-drafts');
expect(second.takeRescuedDraft()).toMatchObject({ subject: 'Kept' });
});
});
describe('every display-mode check knows the overlay', () => {
it('goes through lib/standalone-display.ts', () => {
for (const file of ['components/protocol/mailto-protocol-client.tsx', 'components/protocol/webcal-protocol-client.tsx', 'components/protocol/protocol-launch-handler-provider.tsx', 'lib/protocol-handlers/session.ts']) {
const source = readFileSync(join(process.cwd(), file), 'utf8');
expect(source, file).not.toContain('display-mode: standalone');
expect(source, file).toContain('isStandaloneDisplayMode');
}
});
});
+171
View File
@@ -0,0 +1,171 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { canPickFolder, createUniqueNamer, isPickerCancelled, saveFilesToFolder, type FolderHandle } from '@/lib/attachments/save-to-folder';
class FakeFolder implements FolderHandle {
name = 'Downloads';
files = new Map<string, string>();
failWriteOf: string | null = null;
removed: string[] = [];
refused = new Set<string>();
async getFileHandle(name: string, options?: { create?: boolean }) {
// What Windows and Chromium's File System Access refuse.
const stem = name.split('.')[0].toLowerCase();
if (/^(con|prn|aux|nul|com\d|lpt\d)$/.test(stem)
|| /\.(lnk|url|scf|local)$/i.test(name)
|| name.toLowerCase() === 'desktop.ini'
|| new TextEncoder().encode(name).length > 255
|| this.refused.has(name)) {
throw new TypeError(`Name is not allowed: ${name}`);
}
if (!this.files.has(name)) {
if (!options?.create) throw Object.assign(new Error('missing'), { name: 'NotFoundError' });
this.files.set(name, '');
}
return {
createWritable: async () => ({
write: async (data: unknown) => {
if (this.failWriteOf === name) throw Object.assign(new Error('disk full'), { name: 'QuotaExceededError' });
this.files.set(name, String(data));
},
close: async () => {},
abort: async () => {},
}),
};
}
async removeEntry(name: string) {
this.removed.push(name);
this.files.delete(name);
}
}
const item = (name: string, content: string | null = name) => ({ name, read: async () => content as unknown as BufferSource | null });
const always = () => true;
afterEach(() => {
vi.unstubAllGlobals();
delete (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker;
});
describe('saveFilesToFolder', () => {
it('writes each file and says how many', async () => {
const folder = new FakeFolder();
const result = await saveFilesToFolder(folder, [item('a.txt'), item('b.txt')], always);
expect(result).toEqual({ folder: 'Downloads', total: 2, saved: 2, skipped: 0, stopped: null });
expect(folder.files.get('a.txt')).toBe('a.txt');
});
it('gives same-named attachments their own files', async () => {
const folder = new FakeFolder();
await saveFilesToFolder(folder, [item('scan.pdf', 'one'), item('scan.pdf', 'two'), item('scan.pdf', 'three')], always);
expect([...folder.files.entries()]).toEqual([['scan.pdf', 'one'], ['scan (1).pdf', 'two'], ['scan (2).pdf', 'three']]);
});
it('never overwrites a file already in the folder', async () => {
const folder = new FakeFolder();
folder.files.set('report.pdf', 'precious');
await saveFilesToFolder(folder, [item('report.pdf', 'new')], always);
expect(folder.files.get('report.pdf')).toBe('precious');
expect(folder.files.get('report (1).pdf')).toBe('new');
});
it('stops at a failed write, keeps what is saved, and leaves no empty file', async () => {
const folder = new FakeFolder();
folder.failWriteOf = 'b.txt';
const result = await saveFilesToFolder(folder, [item('a.txt'), item('b.txt'), item('c.txt')], always);
expect(result).toMatchObject({ saved: 1, total: 3, stopped: 'write' });
expect([...folder.files.keys()]).toEqual(['a.txt']);
expect(folder.removed).toEqual(['b.txt']);
});
it('skips a file that cannot be fetched and goes on, as the zip does', async () => {
const folder = new FakeFolder();
const broken = { name: 'gone.bin', read: async () => { throw new Error('404'); } };
const result = await saveFilesToFolder(folder, [item('a.txt'), broken, item('c.txt')], always);
expect(result).toMatchObject({ saved: 2, skipped: 1, stopped: null });
});
it('asks the policy before every file, and a policy turning on stops the rest', async () => {
const folder = new FakeFolder();
let asked = 0;
// On for the first file's two checks, then off.
const allowed = () => ++asked <= 2;
const read = vi.fn(async () => 'x' as unknown as BufferSource);
const result = await saveFilesToFolder(folder, [{ name: 'a', read }, { name: 'b', read }], allowed);
expect(result).toMatchObject({ saved: 1, stopped: 'policy' });
expect(read).toHaveBeenCalledTimes(1);
expect([...folder.files.keys()]).toEqual(['a']);
});
it('writes nothing fetched while the policy turned on', async () => {
const folder = new FakeFolder();
let on = true;
const read = vi.fn(async () => { on = false; return 'x' as unknown as BufferSource; });
const result = await saveFilesToFolder(folder, [{ name: 'a', read }], () => on);
expect(result).toMatchObject({ saved: 0, stopped: 'policy' });
expect(folder.files.size).toBe(0);
});
it('keeps a sender\'s path out of the file name', async () => {
const folder = new FakeFolder();
await saveFilesToFolder(folder, [item('../../evil.exe'), item('C:\\Windows\\win.ini'), item('a:b?.txt'), item('...')], always);
expect([...folder.files.keys()]).toEqual(['evil.exe', 'win.ini', 'a_b_.txt', 'attachment']);
});
});
describe('names a file system or the shell keeps for itself', () => {
it('renames them, so the attachment is still saved', async () => {
const folder = new FakeFolder();
const names = ['CON', 'NUL.txt', 'com1.tar.gz', 'desktop.ini', 'x.lnk', 'x.url', 'x.scf', 'X.LNK'];
const result = await saveFilesToFolder(folder, names.map((name) => item(name)), always);
expect(result).toMatchObject({ saved: names.length, skipped: 0, stopped: null });
expect([...folder.files.keys()]).toEqual(['_CON', '_NUL.txt', '_com1.tar.gz', '_desktop.ini', 'x.lnk.txt', 'x.url.txt', 'x.scf.txt', 'X.LNK.txt']);
});
it('leaves names that only look like them', async () => {
const folder = new FakeFolder();
await saveFilesToFolder(folder, ['context.txt', 'console.log', 'nullable.ts', 'lnk.pdf'].map((name) => item(name)), always);
expect([...folder.files.keys()]).toEqual(['context.txt', 'console.log', 'nullable.ts', 'lnk.pdf']);
});
it('shortens a name over the limit, in bytes, keeping the extension and whole characters', async () => {
const folder = new FakeFolder();
const long = `${'ü'.repeat(200)}.pdf`;
const result = await saveFilesToFolder(folder, [item(long), item(long)], always);
expect(result.saved).toBe(2);
const [first, second] = [...folder.files.keys()];
expect(first.endsWith('.pdf')).toBe(true);
expect(first).not.toContain('\uFFFD');
expect(new TextEncoder().encode(first).length).toBeLessThanOrEqual(255);
expect(new TextEncoder().encode(second).length).toBeLessThanOrEqual(255);
expect(second.endsWith(' (1).pdf')).toBe(true);
});
it('skips one name the folder still refuses and goes on to the rest', async () => {
const folder = new FakeFolder();
folder.refused.add('odd.bin');
const result = await saveFilesToFolder(folder, [item('a.txt'), item('odd.bin'), item('c.txt')], always);
expect(result).toMatchObject({ saved: 2, skipped: 1, stopped: null });
expect([...folder.files.keys()]).toEqual(['a.txt', 'c.txt']);
});
});
describe('the helpers', () => {
it('sees the picker only where the browser has it', () => {
expect(canPickFolder()).toBe(false);
(window as unknown as { showDirectoryPicker: unknown }).showDirectoryPicker = () => {};
expect(canPickFolder()).toBe(true);
});
it('tells a closed picker from a failure', () => {
expect(isPickerCancelled(Object.assign(new Error('x'), { name: 'AbortError' }))).toBe(true);
expect(isPickerCancelled(Object.assign(new Error('x'), { name: 'SecurityError' }))).toBe(false);
expect(isPickerCancelled(null)).toBe(false);
});
it('numbers zip entries like before', () => {
const name = createUniqueNamer();
expect(['a.txt', 'a.txt', 'a.txt', '', ''].map(name)).toEqual(['a.txt', 'a (1).txt', 'a (2).txt', 'attachment', 'attachment (1)']);
});
});
+47
View File
@@ -31,6 +31,53 @@ afterEach(() => {
while (temps.length) rmSync(temps.pop()!, { recursive: true, force: true });
});
describe('the desktop trust path', () => {
it('fails a server build that still holds it', () => {
const dir = tempDir();
put(dir, 'server.js');
put(dir, '.next/server/chunks/123.js', 'holder[Symbol.for("bulwark.desktop.trustedJmapOrigins")]');
expect(guardStandalone(dir, { desktopBuild: false })).toEqual(['.next/server/chunks/123.js (desktop trust path in a server build)']);
});
it('fails a server build that still reads the flag at runtime', () => {
const dir = tempDir();
put(dir, 'server.js');
put(dir, '.next/server/chunks/9.js', 'let a="1"===process.env.NEXT_PUBLIC_BULWARK_DESKTOP');
expect(guardStandalone(dir, { desktopBuild: false })).toHaveLength(1);
});
it('passes a server build whose server.js holds the config, with the name as a JSON key', () => {
const dir = tempDir();
put(dir, 'server.js', 'const nextConfig = {"env":{"NEXT_PUBLIC_BULWARK_LITE":"","NEXT_PUBLIC_BULWARK_DESKTOP":""}}');
expect(guardStandalone(dir, { desktopBuild: false })).toEqual([]);
});
it('fails the bracket form of the read too', () => {
const dir = tempDir();
put(dir, 'server.js');
put(dir, '.next/server/chunks/9.js', 'let a=process.env["NEXT_PUBLIC_BULWARK_DESKTOP"]');
expect(guardStandalone(dir, { desktopBuild: false })).toHaveLength(1);
});
it('passes the desktop build itself', () => {
const dir = tempDir();
put(dir, 'server.js');
put(dir, '.next/server/chunks/123.js', 'holder[Symbol.for("bulwark.desktop.trustedJmapOrigins")]');
expect(guardStandalone(dir, { desktopBuild: true })).toEqual([]);
});
it('fails the command line check on a server build', () => {
const dir = tempDir();
put(dir, 'server.js');
put(dir, '.next/server/chunks/123.js', 'bulwark.desktop.trustedJmapOrigins');
const env = { ...process.env };
delete env.NEXT_PUBLIC_BULWARK_DESKTOP;
const run = spawnSync(process.execPath, [SCRIPT, dir], { encoding: 'utf8', env });
expect(run.status).toBe(1);
expect(run.stderr).toContain('desktop trust path');
});
});
describe('standalone guard check', () => {
it('fails the build on a planted signing key', () => {
const dir = tempDir();
+39
View File
@@ -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);
+122
View File
@@ -0,0 +1,122 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { tokenLockName, withTokenLock } from '@/lib/auth/token-lock';
/** Web Locks with one holder at a time and waiters in order, like the browser's. */
function fakeLocks() {
const queues = new Map<string, Promise<unknown>>();
const held = new Set<string>();
const names: string[] = [];
return {
names,
request(name: string, options: { signal?: AbortSignal; ifAvailable?: boolean }, callback: (lock: unknown) => unknown) {
names.push(name);
if (options.ifAvailable && held.has(name)) return Promise.resolve(callback(null));
const previous = queues.get(name) ?? Promise.resolve();
const run = new Promise((resolve, reject) => {
let aborted = false;
options.signal?.addEventListener('abort', () => {
aborted = true;
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
});
void previous.catch(() => {}).then(() => {
if (aborted) return;
held.add(name);
Promise.resolve(callback({})).then(resolve, reject).finally(() => held.delete(name));
});
});
queues.set(name, run.catch(() => {}));
return run;
},
};
}
function stubLocks(locks: unknown) {
Object.defineProperty(navigator, 'locks', { value: locks, configurable: true });
}
afterEach(() => {
stubLocks(undefined);
vi.restoreAllMocks();
});
describe('withTokenLock', () => {
it('runs one renewal per slot at a time, so the second sends the token the first wrote', async () => {
stubLocks(fakeLocks());
const order: string[] = [];
let releaseFirst = () => {};
const first = withTokenLock(1, () => new Promise<string>((resolve) => {
order.push('first starts');
releaseFirst = () => {
order.push('first ends');
resolve('a');
};
}));
const second = withTokenLock(1, async () => {
order.push('second starts');
return 'b';
});
await new Promise((resolve) => setTimeout(resolve, 5));
expect(order).toEqual(['first starts']);
releaseFirst();
await expect(Promise.all([first, second])).resolves.toEqual(['a', 'b']);
expect(order).toEqual(['first starts', 'first ends', 'second starts']);
});
it('keeps slots apart: each login has its own refresh token', async () => {
const locks = fakeLocks();
stubLocks(locks);
await withTokenLock(0, async () => 'x');
await withTokenLock(2, async () => 'y');
expect(new Set(locks.names)).toEqual(new Set([tokenLockName(0), tokenLockName(2)]));
expect(tokenLockName(0)).not.toBe(tokenLockName(2));
});
it('runs at once without Web Locks', async () => {
await expect(withTokenLock(1, async () => 'done')).resolves.toBe('done');
});
it('goes ahead when another window holds the lock too long, rather than never renewing', async () => {
stubLocks(fakeLocks());
void withTokenLock(1, () => new Promise(() => {}));
await new Promise((resolve) => setTimeout(resolve, 5));
await expect(withTokenLock(1, async () => 'went ahead', { waitMs: 10 })).resolves.toBe('went ahead');
});
it('says when it had to wait for another window, and when it did not', async () => {
stubLocks(fakeLocks());
const turns: boolean[] = [];
let release = () => {};
const first = withTokenLock(1, ({ contended }) => {
turns.push(contended);
return new Promise<void>((resolve) => { release = resolve; });
});
await new Promise((resolve) => setTimeout(resolve, 5));
const second = withTokenLock(1, async ({ contended }) => { turns.push(contended); });
await new Promise((resolve) => setTimeout(resolve, 5));
release();
await Promise.all([first, second]);
expect(turns).toEqual([false, true]);
await withTokenLock(1, async ({ contended }) => { turns.push(contended); });
expect(turns).toEqual([false, true, false]);
});
it('does nothing when the caller calls it off during the wait: a sign-out', async () => {
stubLocks(fakeLocks());
void withTokenLock(1, () => new Promise(() => {}));
await new Promise((resolve) => setTimeout(resolve, 5));
const signOut = new AbortController();
const work = vi.fn(async () => 'renewed');
const queued = withTokenLock(1, work, { signal: signOut.signal });
await new Promise((resolve) => setTimeout(resolve, 5));
signOut.abort();
await expect(queued).rejects.toThrow();
expect(work).not.toHaveBeenCalled();
});
it('hands the work\'s own error to the caller and does not run it twice', async () => {
stubLocks(fakeLocks());
const work = vi.fn(async () => { throw new TypeError('Failed to fetch'); });
await expect(withTokenLock(1, work)).rejects.toThrow('Failed to fetch');
expect(work).toHaveBeenCalledTimes(1);
});
});
+130
View File
@@ -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)]');
}
});
});
+5
View File
@@ -1,3 +1,5 @@
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
/**
* Utilities for multi-account support:
* - Account ID generation
@@ -136,6 +138,9 @@ export function isHttp2Available(): boolean {
* once HTTP/2+ is observed, otherwise returns {@link MAX_ACCOUNTS_HTTP1}.
*/
export function getMaxAccounts(): number {
// The cap is for the push streams sharing one http/1.1 origin. The embedded
// server speaks http/1.1, but each account streams from its own mail server.
if (IS_DESKTOP_BUILD) return MAX_ACCOUNT_SLOTS;
return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1;
}
+6
View File
@@ -1,6 +1,8 @@
/**
* Multi-server JMAP support: schema, parsing, lookup, and redaction helpers.
*/
import { isDesktopTrustedServer } from '@/lib/desktop/trusted-servers';
export interface JmapServerOAuthConfig {
clientId?: string;
@@ -223,6 +225,10 @@ export function resolveTrustedJmapUrl(
}
const matched = servers.find((s) => normalizeUrl(s.url) === target);
if (matched) return matched.url;
// A server the user approved in the desktop app. Spelled out, not IS_DESKTOP_BUILD:
// only this form lets the bundler drop the branch and the module behind it, so
// that no other build holds this path at all (scripts/standalone-guard.mjs checks).
if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && isDesktopTrustedServer(requestedUrl)) return trimUrl(requestedUrl);
// No match - caller decides whether to honor the request anyway (e.g. when
// allowCustomJmapEndpoint is enabled).
return null;
+215
View File
@@ -0,0 +1,215 @@
/**
* "Save all attachments to a folder", with the File System Access API.
*
* These writes don't pass saveBlobAs (lib/download-guard.ts), the backstop of
* the device policies, so the caller's `allowed` is asked before each file:
* a policy that turns on while the files are written stops the rest.
*
* The folder handle is an interface, not the DOM type, so the desktop bridge
* or a test can supply its own.
*/
export interface WritableFile {
write(data: Blob | BufferSource): Promise<void>;
close(): Promise<void>;
abort?(): Promise<void>;
}
export interface FolderFile {
createWritable(): Promise<WritableFile>;
}
export interface FolderHandle {
name: string;
getFileHandle(name: string, options?: { create?: boolean }): Promise<FolderFile>;
removeEntry?(name: string): Promise<void>;
}
type DirectoryPicker = (options?: { mode?: 'read' | 'readwrite'; id?: string; startIn?: string }) => Promise<FolderHandle>;
/** Whether this browser can ask for a folder. Without it the zip download is all there is. */
export function canPickFolder(): boolean {
return typeof window !== 'undefined' && typeof (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker === 'function';
}
/**
* Asks where to save. Call it straight from the click, before any await: the
* browser only shows the picker for a user action. Rejects with an AbortError
* when the user closes the picker (see isPickerCancelled).
*/
export function pickFolder(): Promise<FolderHandle> {
const picker = (window as unknown as { showDirectoryPicker: DirectoryPicker }).showDirectoryPicker;
return picker.call(window, { mode: 'readwrite', id: 'bulwark-attachments', startIn: 'downloads' });
}
export function isPickerCancelled(error: unknown): boolean {
return (error as { name?: unknown } | null)?.name === 'AbortError';
}
/** The name's nth alternative: " (n)" before the extension, so duplicates stay recognisable. */
function numberedName(base: string, n: number): string {
if (n === 0) return base;
const dot = base.lastIndexOf('.');
const stem = dot > 0 ? base.slice(0, dot) : base;
const ext = dot > 0 ? base.slice(dot) : '';
return `${stem} (${n})${ext}`;
}
/** Names unique within one batch, for the zip's entries. */
export function createUniqueNamer(fallback = 'attachment'): (raw: string) => string {
const used = new Set<string>();
return (raw) => {
const base = raw || fallback;
let n = 0;
while (used.has(numberedName(base, n))) n++;
const name = numberedName(base, n);
used.add(name);
return name;
};
}
/** Names Windows keeps for devices, with any extension: NUL.txt is NUL. */
const RESERVED_STEM = /^(con|prn|aux|nul|clock\$|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])$/i;
/** Names and extensions the shell acts on, which browsers refuse to create. */
const SHELL_NAMES = new Set(['desktop.ini', 'thumbs.db', 'autorun.inf']);
const SHELL_EXTENSIONS = new Set(['.lnk', '.url', '.scf', '.local', '.pif', '.shb', '.cpl', '.inf']);
/** A file system's limit is 255 bytes. Below it, leaving room for " (n)". */
const MAX_NAME_BYTES = 240;
function truncateName(name: string): string {
const encoder = new TextEncoder();
if (encoder.encode(name).length <= MAX_NAME_BYTES) return name;
const dot = name.lastIndexOf('.');
const ext = dot > 0 && encoder.encode(name.slice(dot)).length <= 32 ? name.slice(dot) : '';
let room = MAX_NAME_BYTES - encoder.encode(ext).length;
let stem = '';
// By code point, so a character is never cut in half.
for (const char of ext ? name.slice(0, dot) : name) {
room -= encoder.encode(char).length;
if (room < 0) break;
stem += char;
}
return stem.trimEnd() + ext;
}
/**
* A name the folder will take: no path parts a sender could smuggle in, no
* characters a file system refuses, and nothing Windows or the shell treats as
* special. Renamed, so the attachment is still saved.
*/
function safeFileName(raw: string, fallback: string): string {
const leaf = raw.split(/[\\/]/).pop() ?? '';
// eslint-disable-next-line no-control-regex
const cleaned = leaf.replace(/[<>:"|?*\x00-\x1f]/g, '_').replace(/[. ]+$/, '').trim();
if (!cleaned || cleaned === '.' || cleaned === '..') return fallback;
const lower = cleaned.toLowerCase();
const stem = lower.split('.')[0];
const dot = lower.lastIndexOf('.');
let safe = cleaned;
if (RESERVED_STEM.test(stem) || SHELL_NAMES.has(lower)) safe = `_${cleaned}`;
else if (dot > 0 && SHELL_EXTENSIONS.has(lower.slice(dot))) safe = `${cleaned}.txt`;
return truncateName(safe);
}
/** The folder or the browser refused this name, which says nothing about the next file. */
function isNameRefused(error: unknown): boolean {
const kind = (error as { name?: unknown } | null)?.name;
return kind === 'TypeError' || kind === 'SecurityError';
}
async function existsInFolder(folder: FolderHandle, name: string): Promise<boolean> {
try {
await folder.getFileHandle(name);
return true;
} catch (error) {
const kind = (error as { name?: unknown } | null)?.name;
if (kind === 'NotFoundError') return false;
// A sub-folder of that name: taken as well.
if (kind === 'TypeMismatchError') return true;
throw error;
}
}
/** The first of the name's alternatives neither this batch nor the folder has. */
async function freeFileName(folder: FolderHandle, base: string, claimed: ReadonlySet<string>): Promise<string> {
let n = 0;
while (claimed.has(numberedName(base, n)) || await existsInFolder(folder, numberedName(base, n))) n++;
return numberedName(base, n);
}
export interface FolderSaveItem {
name: string;
/** The file's content, or null when it has none to save. Fetched only when its turn comes. */
read: () => Promise<Blob | BufferSource | null>;
}
export interface FolderSaveResult {
folder: string;
total: number;
saved: number;
/** Files that couldn't be read from the server, or whose name was refused; the rest still went. */
skipped: number;
/** Why the run ended early, when it did. */
stopped: 'policy' | 'write' | null;
}
/**
* Writes the files one at a time. A file that can't be fetched or whose name is
* refused is skipped, like in the zip. A write that fails stops the run, because
* the next one would fail the same way (disk full, permission gone), and the
* result says how many made it. A file already in the folder is never overwritten.
*/
export async function saveFilesToFolder(
folder: FolderHandle,
items: readonly FolderSaveItem[],
allowed: () => boolean,
): Promise<FolderSaveResult> {
const result: FolderSaveResult = { folder: folder.name, total: items.length, saved: 0, skipped: 0, stopped: null };
const claimed = new Set<string>();
for (const item of items) {
if (!allowed()) {
result.stopped = 'policy';
return result;
}
let data: Blob | BufferSource | null;
try {
data = await item.read();
} catch {
data = null;
}
if (data === null) {
result.skipped += 1;
continue;
}
// The policy may have changed during the fetch, and nothing is on disk yet.
if (!allowed()) {
result.stopped = 'policy';
return result;
}
let name: string | null = null;
let writable: WritableFile | null = null;
try {
name = await freeFileName(folder, safeFileName(item.name, 'attachment'), claimed);
claimed.add(name);
const file = await folder.getFileHandle(name, { create: true });
writable = await file.createWritable();
await writable.write(data);
await writable.close();
result.saved += 1;
} catch (error) {
await writable?.abort?.().catch(() => {});
// getFileHandle created an empty file; don't leave it looking saved.
if (name) await folder.removeEntry?.(name).catch(() => {});
if (isNameRefused(error)) {
result.skipped += 1;
continue;
}
result.stopped = 'write';
return result;
}
}
return result;
}
+8 -2
View File
@@ -1,5 +1,7 @@
import { BlockList, isIP } from 'node:net';
import { getClientIP } from '@/lib/admin/session';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { desktopLoginBudgetKey } from '@/lib/desktop/login-budget';
/** Addresses that name no client: loopback and unspecified. */
const NOT_A_CLIENT = new BlockList();
@@ -39,7 +41,11 @@ export function forwardedClientHeaders(clientIp: string | undefined): Record<str
return { 'X-Forwarded-For': clientIp };
}
/** The client address of `request` as the sign-in routes forward and budget it. */
export function loginClientAddress(request: Request): string {
/**
* The client address of `request` as the sign-in routes forward and budget it.
* The desktop build has one client, so there it counts per mail server instead.
*/
export function loginClientAddress(request: Request, serverUrl?: unknown): string {
if (IS_DESKTOP_BUILD) return desktopLoginBudgetKey(serverUrl);
return getClientIP(request);
}
+4 -1
View File
@@ -121,7 +121,9 @@ export type PayloadPurpose =
/** A pairing grant or code record in the shared store (lib/auth/pairing-store.ts). */
| 'pairing-store'
/** A parked mobile hand-off sign-in in the shared store (lib/auth/handoff-store.ts). */
| 'handoff-store';
| 'handoff-store'
/** Which server a desktop account's refresh token belongs to (lib/oauth/custom-upstream.ts). */
| 'custom-upstream';
/**
* Whether a blob minted before purposes were bound may be re-admitted for
@@ -145,6 +147,7 @@ const LEGACY_PAYLOAD_MARKERS: Record<PayloadPurpose, (payload: Record<string, un
'session-age': () => false,
'pairing-store': () => false,
'handoff-store': () => false,
'custom-upstream': () => false,
};
export function encryptPayload(payload: Record<string, unknown>, purpose: PayloadPurpose): string {
+78
View File
@@ -0,0 +1,78 @@
/**
* One token renewal at a time across the windows of this browser.
*
* Refresh tokens rotate, and the token route reads the current one from a
* cookie. Two windows renewing at once send the same token twice; on Stalwart
* 1.0 a superseded token reused past its retry window counts as a replay and
* revokes the whole grant. Held across the request, so the next window's
* request carries the cookie the first one's answer wrote.
*
* Without Web Locks, or after waiting too long for a window that hangs, the
* work runs anyway: never worse than having no lock.
*/
interface LockManagerLike {
request<T>(name: string, options: { signal?: AbortSignal; ifAvailable?: boolean }, callback: (lock: unknown) => T | Promise<T>): Promise<T>;
}
/** How long a renewal waits for another window's before going ahead. Above the route's own timeouts. */
export const TOKEN_LOCK_WAIT_MS = 30_000;
const NOT_HELD = Symbol('not held');
export interface TokenLockTurn {
/**
* Another window held the lock first, so it may have renewed already. A
* renewal that was going to force one should take the cached token instead:
* an identity provider can refuse a second one this soon (Rauthy `nbf`, #552).
*/
contended: boolean;
}
function lockManager(): LockManagerLike | null {
const locks = typeof navigator !== 'undefined' ? (navigator as { locks?: LockManagerLike }).locks : undefined;
return locks && typeof locks.request === 'function' ? locks : null;
}
export function tokenLockName(slot: number): string {
return `bulwark-token-refresh:${slot}`;
}
export async function withTokenLock<T>(
slot: number,
work: (turn: TokenLockTurn) => Promise<T>,
options: { waitMs?: number; signal?: AbortSignal } = {},
): Promise<T> {
const locks = lockManager();
if (!locks) return work({ contended: false });
const name = tokenLockName(slot);
let started = false;
const run = (contended: boolean) => {
started = true;
return work({ contended });
};
const gaveUp = new AbortController();
const forwardAbort = () => gaveUp.abort();
options.signal?.addEventListener('abort', forwardAbort);
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const first = await locks.request<T | typeof NOT_HELD>(name, { ifAvailable: true }, (lock) => (lock ? run(false) : NOT_HELD));
if (first !== NOT_HELD) return first as T;
timer = setTimeout(() => gaveUp.abort(), options.waitMs ?? TOKEN_LOCK_WAIT_MS);
return await locks.request<T>(name, { signal: gaveUp.signal }, () => {
clearTimeout(timer);
return run(true);
});
} catch (error) {
// An error from the work goes to the caller, who decides what it means. So
// does a caller calling it off (a sign-out). Only the lock itself failing,
// or the wait running out, runs the work.
if (started || options.signal?.aborted) throw error;
return run(true);
} finally {
clearTimeout(timer);
options.signal?.removeEventListener('abort', forwardAbort);
}
}
+24
View File
@@ -10,19 +10,43 @@
* waits at most `timeoutMs` for all of them together.
*/
import { getDesktopBridge } from '@/lib/desktop/bridge';
export type ComposeFlush = () => Promise<void>;
/** Less than the desktop shell waits for a closing window. */
const DESKTOP_FLUSH_MS = 3000;
let nextId = 1;
const flushes = new Map<number, ComposeFlush>();
const waiters = new Set<(flush: ComposeFlush) => void>();
let stopDesktopFlush: (() => void) | null = null;
/**
* In the desktop app, closing a window or quitting saves the open drafts first. The
* shell only waits while a composer is here, so a window without one closes at once.
*/
function syncDesktopFlush(): void {
if (flushes.size === 0) {
stopDesktopFlush?.();
stopDesktopFlush = null;
return;
}
if (stopDesktopFlush) return;
const lifecycle = getDesktopBridge()?.lifecycle;
if (lifecycle) stopDesktopFlush = lifecycle.onFlushRequest(() => flushComposers(DESKTOP_FLUSH_MS));
}
/** Register a composer's flush; returns the unregister function. */
export function registerComposeFlush(flush: ComposeFlush): () => void {
const id = nextId++;
flushes.set(id, flush);
for (const waiter of [...waiters]) waiter(flush);
syncDesktopFlush();
return () => {
flushes.delete(id);
syncDesktopFlush();
};
}
+233
View File
@@ -0,0 +1,233 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BulwarkDesktopBridge } from '../types';
/** The webmail's view of the bridge, and the http server route. */
// The client's first import compiles a good part of the app.
vi.setConfig({ testTimeout: 60_000 });
function setBridge(bridge: Partial<BulwarkDesktopBridge> | undefined) {
window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined;
}
afterEach(() => {
setBridge(undefined);
vi.unstubAllEnvs();
vi.unstubAllGlobals();
sessionStorage.clear();
});
describe('feature detection', () => {
it('finds nothing in a browser', async () => {
const { getDesktopBridge, isDesktopApp } = await import('../bridge');
expect(getDesktopBridge()).toBeNull();
expect(isDesktopApp()).toBe(false);
});
it('finds the bridge in the app', async () => {
const { isDesktopApp } = await import('../bridge');
setBridge({ version: 1, platform: 'win32' });
expect(isDesktopApp()).toBe(true);
});
it('treats a newer bridge major as absent rather than guessing at it', async () => {
const { getDesktopBridge } = await import('../bridge');
setBridge({ version: 2, platform: 'win32' });
expect(getDesktopBridge()).toBeNull();
});
});
describe('web push', () => {
beforeEach(() => {
Object.defineProperty(navigator, 'serviceWorker', { value: {}, configurable: true });
vi.stubGlobal('PushManager', function PushManager() {});
vi.stubGlobal('Notification', function Notification() {});
});
it('is on offer in a browser', async () => {
const { isWebPushSupported } = await import('@/lib/web-push');
expect(isWebPushSupported()).toBe(true);
});
it('is off in the app: Electron has the API, but subscribing fails', async () => {
const { isWebPushSupported } = await import('@/lib/web-push');
setBridge({ version: 1, platform: 'win32' });
expect(isWebPushSupported()).toBe(false);
});
});
describe('desktopJmapBase', () => {
async function load() {
vi.resetModules();
return import('../jmap-route');
}
it('leaves every URL alone outside the desktop build', async () => {
const { desktopJmapBase, desktopJmapOrigin } = await load();
expect(desktopJmapBase('http://192.168.1.5:8080')).toBe('http://192.168.1.5:8080');
expect(desktopJmapOrigin('http://192.168.1.5:8080')).toBe('http://192.168.1.5:8080');
});
it('moves a plain http server onto the same-origin route', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const { desktopJmapBase, desktopJmapOriginId } = await load();
const id = desktopJmapOriginId('http://192.168.1.5:8080');
expect(id).toMatch(/^[A-Za-z0-9_-]+$/);
expect(Buffer.from(id, 'base64url').toString()).toBe('http://192.168.1.5:8080');
expect(desktopJmapBase('http://192.168.1.5:8080')).toBe(`${window.location.origin}/__jmap/${id}`);
});
it('keeps an https server direct', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const { desktopJmapBase, desktopJmapOrigin } = await load();
expect(desktopJmapBase('https://mail.lan')).toBe('https://mail.lan');
expect(desktopJmapOrigin('https://mail.lan')).toBe('https://mail.lan');
});
});
describe('JMAPClient on an approved http server', () => {
const SESSION = {
capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500 }, 'urn:ietf:params:jmap:mail': {} },
accounts: { a1: { name: 'ent-alice', isPersonal: true, isReadOnly: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } } },
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a1' },
username: 'ent-alice',
apiUrl: 'http://stalwart.local:8080/jmap/',
downloadUrl: 'http://stalwart.local:8080/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
uploadUrl: 'http://stalwart.local:8080/jmap/upload/{accountId}/',
eventSourceUrl: 'http://stalwart.local:8080/jmap/eventsource/',
state: 's1',
};
function stubFetch() {
const urls: string[] = [];
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
urls.push(url);
if (url.includes('/.well-known/jmap')) return new Response(JSON.stringify(SESSION), { status: 200 });
return new Response(JSON.stringify({ methodResponses: [], sessionState: 's1' }), { status: 200 });
}));
return urls;
}
async function connect(serverUrl: string) {
vi.resetModules();
const { JMAPClient } = await import('@/lib/jmap/client');
const client = new JMAPClient(serverUrl, 'ent-alice', 'pw');
await client.connect();
const session = (client as unknown as { session: typeof SESSION }).session;
client.disconnect();
return session;
}
it('sends discovery and every session URL through /__jmap in the desktop build', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const urls = stubFetch();
const noteSession = vi.fn();
setBridge({ version: 1, platform: 'win32', servers: { prepare: vi.fn(), noteSession } });
const session = await connect('http://192.168.1.5:8080');
const base = `${window.location.origin}/__jmap/${Buffer.from('http://192.168.1.5:8080').toString('base64url')}`;
expect(urls[0]).toBe(`${base}/.well-known/jmap`);
expect(session.apiUrl).toBe(`${base}/jmap/`);
expect(session.downloadUrl).toBe(`${base}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`);
expect(session.uploadUrl).toBe(`${base}/jmap/upload/{accountId}/`);
expect(session.eventSourceUrl).toBe(`${base}/jmap/eventsource/`);
expect(urls.filter((u) => u.startsWith('http://192.168.1.5'))).toEqual([]);
expect(noteSession).toHaveBeenCalledWith('http://192.168.1.5:8080', expect.objectContaining({ apiUrl: `${base}/jmap/` }));
});
it('a server build talks to the server directly, as it always did', async () => {
const urls = stubFetch();
const session = await connect('http://192.168.1.5:8080');
expect(urls[0]).toBe('http://192.168.1.5:8080/.well-known/jmap');
expect(session.apiUrl).toBe('http://192.168.1.5:8080/jmap/');
});
});
describe('runDesktopBrowserSignIn', () => {
const ENDPOINTS = { authorization_endpoint: 'https://mail.example/authorize', client_id: 'registered-client', scope: 'openid email' };
function authBridge(result: unknown) {
const completeBrowserSignIn = vi.fn(async (_flowId: string, url: string) => (
typeof result === 'function' ? (result as (u: string) => unknown)(url) : result
));
const bridge = {
version: 1,
platform: 'win32',
servers: { prepare: vi.fn(async () => ({ ok: true, approved: false })), noteSession: vi.fn() },
auth: {
beginBrowserSignIn: vi.fn(async () => ({ flowId: 'f1', redirectUri: 'http://127.0.0.1:51234/bulwark-desktop/callback' })),
completeBrowserSignIn,
cancelBrowserSignIn: vi.fn(),
},
} as unknown as BulwarkDesktopBridge;
setBridge(bridge);
return bridge;
}
const options = { serverUrl: 'https://mail.example', callbackPath: '/en/auth/callback', slot: 1, remember: true, addAccount: false };
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(ENDPOINTS), { status: 200 })));
});
it('runs PKCE with the loopback redirect and parks what the callback page needs', async () => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
let authorize = new URL('https://x');
authBridge((url: string) => {
authorize = new URL(url);
return { ok: true, code: 'the-code', state: authorize.searchParams.get('state') };
});
const outcome = await runDesktopBrowserSignIn(options);
expect(outcome.kind).toBe('callback');
expect(authorize.origin + authorize.pathname).toBe('https://mail.example/authorize');
expect(authorize.searchParams.get('client_id')).toBe('registered-client');
expect(authorize.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:51234/bulwark-desktop/callback');
expect(authorize.searchParams.get('code_challenge_method')).toBe('S256');
expect(authorize.searchParams.get('code_challenge')).toBeTruthy();
expect(sessionStorage.getItem('oauth_redirect_uri')).toBe('http://127.0.0.1:51234/bulwark-desktop/callback');
expect(sessionStorage.getItem('oauth_client_id')).toBe('registered-client');
expect(sessionStorage.getItem('oauth_server_url')).toBe('https://mail.example');
expect(sessionStorage.getItem('oauth_cookie_slot')).toBe('1');
expect(sessionStorage.getItem('oauth_code_verifier')).toBeTruthy();
});
it('rejects an answer with another state', async () => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
authBridge({ ok: true, code: 'c', state: 'forged' });
const outcome = await runDesktopBrowserSignIn(options);
expect(outcome).toEqual({ kind: 'failed', error: 'state_mismatch' });
expect(sessionStorage.getItem('oauth_code_verifier')).toBeNull();
});
it('sends nothing to a server the user declined', async () => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
const bridge = authBridge({ ok: false, error: 'cancelled' });
bridge.servers!.prepare = vi.fn(async () => ({ ok: false as const, reason: 'declined' as const }));
const outcome = await runDesktopBrowserSignIn(options);
expect(outcome.kind).toBe('declined');
expect(bridge.auth!.beginBrowserSignIn).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it('says so when the server has no OAuth, and closes the listener', async () => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 })));
const bridge = authBridge({ ok: true });
const outcome = await runDesktopBrowserSignIn(options);
expect(outcome.kind).toBe('unavailable');
expect(bridge.auth!.cancelBrowserSignIn).toHaveBeenCalledWith('f1');
expect(bridge.auth!.completeBrowserSignIn).not.toHaveBeenCalled();
});
});
describe('isDesktopBlockedRoute', () => {
it('closes pairing and hand-off, and nothing else', async () => {
const { isDesktopBlockedRoute } = await import('../blocked-routes');
for (const path of ['/api/auth/pair/create', '/api/auth/pair/token', '/api/auth/handoff/redeem', '/api/auth/impersonate']) {
expect(isDesktopBlockedRoute(path), path).toBe(true);
}
for (const path of ['/api/auth/session', '/api/auth/token', '/api/auth/pairs', '/en/login']) {
expect(isDesktopBlockedRoute(path), path).toBe(false);
}
});
});
@@ -0,0 +1,231 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* A desktop account's refresh token belongs to a server only the app knows. A
* TOTP or browser sign-in has to be able to refresh there, never at a configured
* server, and never with a configured client secret.
*/
vi.mock('next/server', () => ({
NextResponse: {
json: (data: unknown, init?: { status?: number }) => ({
json: async () => data,
status: init?.status ?? 200,
}),
},
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
}));
vi.mock('@/lib/oauth/cookie-config', () => ({
getCookieOptions: () => ({ path: '/', maxAge: 2592000 }),
getSignInCookieOptions: (remember: boolean) => ({ path: '/', ...(remember ? { maxAge: 2592000 } : {}) }),
}));
const config: Record<string, unknown> = {};
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback),
},
}));
vi.mock('@/lib/auth/crypto', () => ({
encryptPayload: (payload: Record<string, unknown>) => JSON.stringify(payload),
decryptPayload: (value: string) => JSON.parse(value),
}));
const isPublicHttpUrl = vi.hoisted(() => vi.fn(async () => true));
vi.mock('@/lib/security/url-guard', async (original) => ({
...(await original<object>()),
isPublicHttpUrl,
}));
vi.mock('@/lib/stalwart/auth-context', () => ({
clearStalwartAuthContextInStore: vi.fn(),
readStalwartAuthContextFromStore: () => null,
}));
vi.setConfig({ testTimeout: 60_000 });
class FakeCookies {
store = new Map<string, string>();
get(name: string) {
const value = this.store.get(name);
return value === undefined ? undefined : { name, value };
}
set(name: string, value: string) {
this.store.set(name, value);
}
delete(arg: string | { name: string }) {
this.store.delete(typeof arg === 'string' ? arg : arg.name);
}
}
let cookieStore: FakeCookies;
vi.mock('next/headers', () => ({ cookies: async () => cookieStore }));
const SERVER = 'https://mail.typed.example';
const CONFIGURED = 'https://configured.example';
const metadata = (base: string) => ({
issuer: base,
authorization_endpoint: `${base}/authorize`,
token_endpoint: `${base}/auth/token`,
revocation_endpoint: `${base}/auth/revoke`,
});
function stubUpstream(impl?: (url: string, body: string) => Response) {
const calls: { url: string; body: string }[] = [];
vi.stubGlobal('fetch', vi.fn(async (input: string | URL, init?: RequestInit) => {
const url = String(input);
const base = new URL(url).origin;
if (url.includes('/.well-known/')) return new Response(JSON.stringify(metadata(base)), { status: 200 });
const body = String(init?.body ?? '');
calls.push({ url, body });
return impl ? impl(url, body) : new Response(JSON.stringify({ access_token: 'new-access', expires_in: 3600 }), { status: 200 });
}));
return calls;
}
function request(method: string, params: Record<string, string> = {}, body?: unknown): unknown {
return {
method,
headers: new Headers({ 'sec-fetch-site': 'same-origin' }),
nextUrl: { searchParams: new URLSearchParams(params), basePath: '' },
json: async () => body,
};
}
async function loadRoute() {
vi.resetModules();
return import('@/app/api/auth/token/route');
}
beforeEach(() => {
cookieStore = new FakeCookies();
for (const key of Object.keys(config)) delete config[key];
isPublicHttpUrl.mockReset();
isPublicHttpUrl.mockImplementation(async () => true);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe('PUT /api/auth/token in the desktop build', () => {
beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
cookieStore.set('jmap_rt', 'refresh-1');
cookieStore.set('jmap_rtu', JSON.stringify({ s: SERVER, c: 'client-from-register' }));
});
it('refreshes at the server of the record, with its client', async () => {
const calls = stubUpstream();
const { PUT } = await loadRoute();
const res = (await PUT(request('PUT', { force: 'true' }) as Parameters<typeof PUT>[0])) as unknown as { status: number };
expect(res.status).toBe(200);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe(`${SERVER}/auth/token`);
const sent = new URLSearchParams(calls[0].body);
expect(sent.get('client_id')).toBe('client-from-register');
expect(sent.get('refresh_token')).toBe('refresh-1');
});
it('goes to the record even with a configured server, and never sends its secret', async () => {
config.jmapServerUrl = CONFIGURED;
config.oauthClientId = 'configured-client';
config.oauthClientSecret = 'configured-secret';
const calls = stubUpstream();
const { PUT } = await loadRoute();
await PUT(request('PUT', { force: 'true' }) as Parameters<typeof PUT>[0]);
expect(calls[0].url).toBe(`${SERVER}/auth/token`);
const sent = new URLSearchParams(calls[0].body);
expect(sent.get('client_secret')).toBeNull();
expect(sent.get('client_id')).toBe('client-from-register');
});
it('answers 503, not 500, when the mail server is unreachable, so the session stays', async () => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('fetch failed'); }));
const { PUT } = await loadRoute();
const res = (await PUT(request('PUT', { force: 'true' }) as Parameters<typeof PUT>[0])) as unknown as { status: number };
expect(res.status).toBe(503);
expect(cookieStore.get('jmap_rt')).toBeDefined();
});
it('refuses a record whose address no longer passes the guard', async () => {
isPublicHttpUrl.mockImplementation(async () => false);
const calls = stubUpstream();
const { PUT } = await loadRoute();
const res = (await PUT(request('PUT', { force: 'true' }) as Parameters<typeof PUT>[0])) as unknown as { status: number };
expect(calls).toHaveLength(0);
expect(res.status).toBe(503);
});
});
describe('POST /api/auth/token in the desktop build', () => {
const body = {
code: 'code-1',
code_verifier: 'verifier',
redirect_uri: 'http://127.0.0.1:51234/bulwark-desktop/callback',
slot: 2,
custom_server_url: SERVER,
client_id: 'client-from-register',
};
beforeEach(() => vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'));
it('exchanges at the typed server and leaves a record for the refresh', async () => {
const calls = stubUpstream(() => new Response(JSON.stringify({ access_token: 'a', refresh_token: 'r', expires_in: 3600 }), { status: 200 }));
const { POST } = await loadRoute();
const res = (await POST(request('POST', {}, body) as Parameters<typeof POST>[0])) as unknown as { status: number };
expect(res.status).toBe(200);
expect(calls[0].url).toBe(`${SERVER}/auth/token`);
expect(new URLSearchParams(calls[0].body).get('redirect_uri')).toBe(body.redirect_uri);
expect(JSON.parse(cookieStore.get('jmap_rtu_2')!.value)).toEqual({ s: SERVER, c: 'client-from-register' });
});
it('refuses a private address the user did not approve', async () => {
isPublicHttpUrl.mockImplementation(async () => false);
const calls = stubUpstream();
const { POST } = await loadRoute();
const res = (await POST(request('POST', {}, { ...body, custom_server_url: 'http://10.0.0.1' }) as Parameters<typeof POST>[0])) as unknown as { status: number };
expect(res.status).toBe(400);
expect(calls).toHaveLength(0);
});
it('sign-out revokes at the record and removes it', async () => {
cookieStore.set('jmap_rt', 'refresh-1');
cookieStore.set('jmap_rtu', JSON.stringify({ s: SERVER, c: 'client-from-register' }));
const calls = stubUpstream();
const { DELETE } = await loadRoute();
await DELETE(request('DELETE') as Parameters<typeof DELETE>[0]);
expect(calls.map((c) => c.url)).toEqual([`${SERVER}/auth/revoke`]);
expect(cookieStore.get('jmap_rtu')).toBeUndefined();
expect(cookieStore.get('jmap_rt')).toBeUndefined();
});
});
describe('in a server build', () => {
it('ignores a planted record and a custom_server_url', async () => {
config.jmapServerUrl = CONFIGURED;
config.oauthClientId = 'configured-client';
cookieStore.set('jmap_rt', 'refresh-1');
cookieStore.set('jmap_rtu', JSON.stringify({ s: SERVER, c: 'client-from-register' }));
const calls = stubUpstream();
const { PUT } = await loadRoute();
await PUT(request('PUT', { force: 'true' }) as Parameters<typeof PUT>[0]);
expect(calls[0].url).toBe(`${CONFIGURED}/auth/token`);
});
it('answers 503 when the provider cannot be reached, so the client waits it out', async () => {
cookieStore.set('jmap_rt', 'refresh-1');
stubUpstream();
const { PUT } = await loadRoute();
const res = (await PUT(request('PUT', { force: 'true' }) as Parameters<typeof PUT>[0])) as unknown as { status: number };
expect(res.status).toBe(503);
});
});
+147
View File
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* What the login routes do with a private server, with and without the desktop
* build, and whose budget a wrong password spends.
*/
vi.mock('next/server', () => ({
NextResponse: {
json: (data: unknown, init?: { status?: number }) => ({
json: async () => data,
status: init?.status ?? 200,
}),
},
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
}));
vi.mock('next/headers', () => ({
cookies: async () => ({ get: () => undefined, set: vi.fn(), delete: vi.fn(), getAll: () => [] }),
}));
const config: Record<string, unknown> = { allowCustomJmapEndpoint: true };
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback),
},
}));
vi.mock('@/lib/auth/crypto', () => ({
encryptSession: () => 'encrypted-session',
decryptSession: () => null,
decryptPayload: () => null,
encryptPayload: () => 'encrypted',
}));
vi.mock('@/lib/stalwart/auth-context', () => ({
setStalwartAuthContext: vi.fn(),
setStalwartAuthContextInStore: vi.fn(),
clearStalwartAuthContextInStore: vi.fn(),
readStalwartAuthContextFromStore: () => null,
}));
vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: vi.fn() }));
const resolveJmapIdentity = vi.hoisted(() => vi.fn());
vi.mock('@/lib/auth/verify-jmap-auth', async (original) => ({
...(await original<object>()),
resolveJmapIdentity,
}));
const reserveLoginAttempt = vi.hoisted(() => vi.fn());
vi.mock('@/lib/auth/verify-budget', async (original) => ({
...(await original<object>()),
reserveLoginAttempt,
}));
// The route's first import compiles a good part of the app.
vi.setConfig({ testTimeout: 60_000 });
const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins');
const LAN_SERVER = 'http://192.168.1.5:8080';
function loginRequest(serverUrl: string): unknown {
const headers = new Headers({ 'sec-fetch-site': 'same-origin', host: 'app.bulwark.localhost' });
return {
method: 'POST',
headers,
nextUrl: { searchParams: new URLSearchParams(), basePath: '' },
json: async () => ({ serverUrl, username: 'ent-alice@example.org', password: 'pw' }),
};
}
async function postSession(serverUrl: string) {
vi.resetModules();
const { POST } = await import('@/app/api/auth/session/route');
const res = (await POST(loginRequest(serverUrl) as Parameters<typeof POST>[0])) as unknown as {
status: number;
json: () => Promise<Record<string, unknown>>;
};
return { status: res.status, body: await res.json() };
}
beforeEach(() => {
resolveJmapIdentity.mockReset();
resolveJmapIdentity.mockImplementation(async (url: string) => ({ serverUrl: url, accountName: 'ent-alice@example.org' }));
reserveLoginAttempt.mockReset();
reserveLoginAttempt.mockImplementation(async () => async () => {});
(globalThis as unknown as Record<symbol, ReadonlySet<string>>)[TRUST_KEY] = new Set([LAN_SERVER]);
});
afterEach(() => {
vi.unstubAllEnvs();
delete (globalThis as unknown as Record<symbol, unknown>)[TRUST_KEY];
});
describe('POST /api/auth/session', () => {
it('a server build refuses a private URL, even with the set present', async () => {
const { status, body } = await postSession(LAN_SERVER);
expect(status).toBe(400);
expect(body.error).toBe('Server URL is not allowed');
expect(resolveJmapIdentity).not.toHaveBeenCalled();
});
it('a desktop build still refuses a private server the user did not approve', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const { status } = await postSession('http://192.168.1.6:8080');
expect(status).toBe(400);
expect(resolveJmapIdentity).not.toHaveBeenCalled();
});
it('a desktop build reaches an approved private server, and marks it trusted', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const { status } = await postSession(LAN_SERVER);
expect(status).toBe(200);
expect(resolveJmapIdentity).toHaveBeenCalledTimes(1);
expect(resolveJmapIdentity.mock.calls[0][3]).toMatchObject({ trusted: true });
});
});
describe('the login budget', () => {
it('a server build budgets by client address', async () => {
config.jmapServers = [{ id: 'a', url: LAN_SERVER }];
await postSession(LAN_SERVER);
delete config.jmapServers;
expect(reserveLoginAttempt).toHaveBeenCalledWith('0.0.0.0');
});
it('a desktop build budgets per mail server, so two servers do not share one', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
(globalThis as unknown as Record<symbol, ReadonlySet<string>>)[TRUST_KEY] = new Set([LAN_SERVER, 'http://192.168.1.6:8080']);
await postSession(LAN_SERVER);
await postSession('http://192.168.1.6:8080/');
expect(reserveLoginAttempt.mock.calls.map((c) => c[0])).toEqual([
'server:http://192.168.1.5:8080',
'server:http://192.168.1.6:8080',
]);
});
it('the key is no address, so nothing is forwarded upstream as the client', async () => {
const { forwardedClientHeaders } = await import('@/lib/auth/client-address');
expect(forwardedClientHeaders('server:http://192.168.1.5:8080')).toEqual({});
});
});
@@ -0,0 +1,163 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { BulwarkDesktopBridge } from '../types';
/** What the webmail owes the shell: saving on close, leaving nothing behind on sign-out. */
function setBridge(bridge: Partial<BulwarkDesktopBridge> | undefined) {
window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined;
}
afterEach(() => {
setBridge(undefined);
vi.unstubAllEnvs();
vi.resetModules();
});
describe('saving drafts before a window closes', () => {
function lifecycleBridge() {
const handlers = new Set<() => Promise<unknown>>();
setBridge({
version: 1,
platform: 'win32',
lifecycle: {
onFlushRequest: (handler) => {
handlers.add(handler);
return () => handlers.delete(handler);
},
},
});
return handlers;
}
it('asks to be told only while a composer is open', async () => {
const handlers = lifecycleBridge();
const { registerComposeFlush } = await import('@/lib/compose-flush-registry');
expect(handlers.size).toBe(0);
const stop = registerComposeFlush(async () => {});
expect(handlers.size).toBe(1);
const stopSecond = registerComposeFlush(async () => {});
expect(handlers.size).toBe(1);
stop();
expect(handlers.size).toBe(1);
stopSecond();
expect(handlers.size).toBe(0);
});
it('a request runs every composer\'s flush and answers when they are done', async () => {
const handlers = lifecycleBridge();
const { registerComposeFlush } = await import('@/lib/compose-flush-registry');
const saved: string[] = [];
let release = () => {};
const stopA = registerComposeFlush(async () => {
await new Promise<void>((resolve) => (release = resolve));
saved.push('a');
});
const stopB = registerComposeFlush(async () => {
saved.push('b');
});
let answered = false;
const answer = [...handlers][0]().then(() => (answered = true));
await Promise.resolve();
expect(answered).toBe(false);
release();
await answer;
expect(saved.sort()).toEqual(['a', 'b']);
stopA();
stopB();
});
it('a composer that hangs does not hold the answer past its limit', async () => {
vi.useFakeTimers();
try {
const handlers = lifecycleBridge();
const { registerComposeFlush } = await import('@/lib/compose-flush-registry');
const stop = registerComposeFlush(() => new Promise(() => {}));
let answered = false;
void [...handlers][0]().then(() => (answered = true));
await vi.advanceTimersByTimeAsync(3001);
expect(answered).toBe(true);
stop();
} finally {
vi.useRealTimers();
}
});
it('in a browser nothing is asked of a bridge that is not there', async () => {
const { registerComposeFlush } = await import('@/lib/compose-flush-registry');
expect(() => registerComposeFlush(async () => {})()).not.toThrow();
});
});
describe('the last account on a server', () => {
async function accountsOn(...servers: string[]) {
const { useAccountStore } = await import('@/stores/account-store');
useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null });
const base = { rememberMe: true, lastLoginAt: 0, isConnected: true, hasError: false, isDefault: false, authMode: 'basic' as const };
const ids = servers.map((serverUrl, index) =>
useAccountStore.getState().addAccount({ ...base, label: `u${index}`, serverUrl, username: `u${index}`, displayName: `u${index}`, email: `u${index}@example.org` }),
);
return { ids, remove: (id: string) => useAccountStore.getState().removeAccount(id) };
}
function forgetBridge() {
const forget = vi.fn();
setBridge({ version: 1, platform: 'win32', servers: { prepare: vi.fn(), noteSession: vi.fn(), forget } });
return forget;
}
it('leaving takes the server off the list', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const forget = forgetBridge();
const { ids, remove } = await accountsOn('https://mail.example.org');
remove(ids[0]);
expect(forget).toHaveBeenCalledWith('https://mail.example.org', 'signed-out');
});
it('with another account still there, it stays', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const forget = forgetBridge();
const { ids, remove } = await accountsOn('https://mail.example.org', 'https://mail.example.org/');
remove(ids[0]);
expect(forget).not.toHaveBeenCalled();
remove(ids[1]);
expect(forget).toHaveBeenCalledTimes(1);
});
it('outside the desktop build nothing is called', async () => {
const forget = forgetBridge();
const { ids, remove } = await accountsOn('https://mail.example.org');
remove(ids[0]);
expect(forget).not.toHaveBeenCalled();
});
});
describe('a plain http account in the desktop build', () => {
async function client(serverUrl: string) {
const { JMAPClient } = await import('@/lib/jmap/client');
const instance = new JMAPClient(serverUrl, 'u', 'p');
(instance as unknown as { session: unknown }).session = { eventSourceUrl: `${serverUrl}/jmap/eventsource/`, capabilities: {} };
return instance;
}
it('polls: no push stream to pin the app origin\'s sockets', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect((await client('http://192.0.2.5:8080')).getEventSourceUrl()).toBeNull();
}, 60_000);
it('an https account keeps its stream', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect((await client('https://mail.example.org')).getEventSourceUrl()).toBe('https://mail.example.org/jmap/eventsource/');
}, 60_000);
it('a server build keeps it for any server', async () => {
expect((await client('http://192.0.2.5:8080')).getEventSourceUrl()).toBe('http://192.0.2.5:8080/jmap/eventsource/');
}, 60_000);
it('a blob gets back the type the route took off', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const { withDesktopBlobType } = await import('../jmap-route');
const blob = new Blob(['x'], { type: 'application/octet-stream' });
expect(withDesktopBlobType(blob, 'http://192.0.2.5:8080', 'image/png').type).toBe('image/png');
expect(withDesktopBlobType(blob, 'https://mail.example.org', 'image/png')).toBe(blob);
});
});
+120
View File
@@ -0,0 +1,120 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { build } from 'esbuild';
import { desktopBuildFlag } from '../../../scripts/desktop-build-flag.mjs';
const ROOT = path.resolve(__dirname, '../../..');
const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins');
const PRIVATE_SERVER = 'http://192.168.1.5:8080';
type Holder = Record<symbol, ReadonlySet<string> | undefined>;
function trust(...origins: string[]) {
(globalThis as unknown as Holder)[TRUST_KEY] = new Set(origins);
}
async function loadResolver() {
vi.resetModules();
return (await import('@/lib/admin/jmap-servers')).resolveTrustedJmapUrl;
}
beforeEach(() => {
delete (globalThis as unknown as Holder)[TRUST_KEY];
});
afterEach(() => {
vi.unstubAllEnvs();
delete (globalThis as unknown as Holder)[TRUST_KEY];
});
describe('the trusted set, server build', () => {
it('refuses a private URL even when the set holds it', async () => {
trust('http://192.168.1.5:8080');
const resolve = await loadResolver();
expect(resolve(PRIVATE_SERVER, '', [])).toBeNull();
});
});
describe('the trusted set, desktop build', () => {
beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
});
it('accepts a server main approved', async () => {
trust('http://192.168.1.5:8080');
const resolve = await loadResolver();
expect(resolve(`${PRIVATE_SERVER}/`, '', [])).toBe(PRIVATE_SERVER);
});
it('does not trust everything: another private server stays out', async () => {
trust('http://192.168.1.5:8080');
const resolve = await loadResolver();
expect(resolve('http://192.168.1.6:8080', '', [])).toBeNull();
expect(resolve('http://192.168.1.5:9999', '', [])).toBeNull();
expect(resolve('http://169.254.169.254', '', [])).toBeNull();
});
it('trusts nothing before main has sent a set', async () => {
const resolve = await loadResolver();
expect(resolve(PRIVATE_SERVER, '', [])).toBeNull();
});
it('ignores a URL with credentials in it', async () => {
trust('http://192.168.1.5:8080');
const resolve = await loadResolver();
expect(resolve('http://a:b@192.168.1.5:8080', '', [])).toBeNull();
});
});
describe('the flag is fixed at build time', () => {
/** Compiles lib/desktop/flag.ts the way next.config.ts `env` does and runs it. */
async function flagOfBuild(buildEnv: Record<string, string>, runtimeEnv: Record<string, string>): Promise<boolean> {
const result = await build({
entryPoints: [path.join(ROOT, 'lib/desktop/flag.ts')],
bundle: true,
write: false,
format: 'cjs',
platform: 'node',
define: { 'process.env.NEXT_PUBLIC_BULWARK_DESKTOP': JSON.stringify(desktopBuildFlag(buildEnv as unknown as NodeJS.ProcessEnv)) },
});
const run = new Function('module', 'exports', 'process', result.outputFiles[0].text);
const module = { exports: {} as { IS_DESKTOP_BUILD?: boolean } };
run(module, module.exports, { env: runtimeEnv });
return module.exports.IS_DESKTOP_BUILD === true;
}
it('a runtime env var cannot switch a server build on', async () => {
expect(await flagOfBuild({}, { NEXT_PUBLIC_BULWARK_DESKTOP: '1' })).toBe(false);
});
it('a desktop build is on without any runtime env var', async () => {
expect(await flagOfBuild({ NEXT_PUBLIC_BULWARK_DESKTOP: '1' }, {})).toBe(true);
});
it('next.config.ts always inlines the flag, so an unset one is no runtime read', () => {
const config = readFileSync(path.join(ROOT, 'next.config.ts'), 'utf8');
expect(config).toMatch(/NEXT_PUBLIC_BULWARK_DESKTOP: desktopBuildFlag\(\)/);
expect(desktopBuildFlag({} as unknown as NodeJS.ProcessEnv)).toBe('');
});
it('flag.ts reads the variable in the one form the compiler replaces', () => {
const source = readFileSync(path.join(ROOT, 'lib/desktop/flag.ts'), 'utf8');
expect(source).toContain("process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1'");
});
it('the trust check uses the form the bundler can drop', () => {
for (const file of ['lib/admin/jmap-servers.ts', 'app/api/auth/totp-token-exchange/route.ts']) {
const source = readFileSync(path.join(ROOT, file), 'utf8');
expect(source, file).toMatch(/process\.env\.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && isDesktopTrustedServer/);
expect(source, file).not.toMatch(/IS_DESKTOP_BUILD && isDesktopTrustedServer/);
}
});
it('no image build sets it', () => {
for (const file of ['Dockerfile', 'Dockerfile.lite']) {
expect(readFileSync(path.join(ROOT, file), 'utf8')).not.toContain('NEXT_PUBLIC_BULWARK_DESKTOP');
}
});
});
+10
View File
@@ -0,0 +1,10 @@
/**
* Routes with no meaning in the desktop build: pairing a phone and the mobile
* hand-off both hand out `https://app.bulwark.localhost`, which nothing else
* can reach, and the app has no use for impersonation. proxy.ts answers 404.
*/
const BLOCKED = /^\/api\/auth\/(pair|handoff|impersonate)(\/|$)/;
export function isDesktopBlockedRoute(pathname: string): boolean {
return BLOCKED.test(pathname);
}
+8
View File
@@ -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', '');`;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Finds the desktop app's bridge. Everything here is feature detected: in a
* browser there's no bridge, and a newer bridge version than this code knows
* is treated as absent rather than guessed at.
*/
import { DESKTOP_BRIDGE_VERSION, type BulwarkDesktopBridge } from './types';
export function getDesktopBridge(): BulwarkDesktopBridge | null {
if (typeof window === 'undefined') return null;
const bridge = window.bulwarkDesktop;
if (!bridge || typeof bridge !== 'object') return null;
if (typeof bridge.version !== 'number' || bridge.version > DESKTOP_BRIDGE_VERSION) return null;
return bridge;
}
/** Nothing added for this server stays: see DesktopServersBridge. */
export function forgetDesktopServer(serverUrl: string, reason: 'failed' | 'signed-out'): void {
getDesktopBridge()?.servers?.forget?.(serverUrl, reason);
}
/** True in any window of the desktop app, standalone or on an instance. */
export function isDesktopApp(): boolean {
return getDesktopBridge() !== null;
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Sign-in through the system browser in the desktop build, for any Stalwart.
*
* A window on app.bulwark.localhost can't be a redirect target: Stalwart 1.0
* refuses it for a public client, and identity providers won't embed. So the
* Electron main process listens on http://127.0.0.1:<port> for one redirect and
* hands the code back. The rest is the regular PKCE flow: the same
* sessionStorage keys, then the callback page, which reads the stored redirect
* URI because it can't be rebuilt from this page's origin.
*
* Google and Entra can't work here. Their clients are registered by whoever
* owns them, and in standalone mode nobody does. Those need an instance.
*/
import { apiFetch } from '@/lib/browser-navigation';
import { generateCodeChallenge, generateCodeVerifier, generateState } from '@/lib/oauth/pkce';
import { getDesktopBridge } from './bridge';
export const DESKTOP_REDIRECT_URI_KEY = 'oauth_redirect_uri';
export const DESKTOP_CLIENT_ID_KEY = 'oauth_client_id';
export type DesktopBrowserSignInOutcome =
| { kind: 'callback'; callbackUrl: string }
| { kind: 'unavailable' }
| { kind: 'declined' }
| { kind: 'cancelled' }
| { kind: 'failed'; error: string };
interface DesktopOAuthAnswer {
authorization_endpoint: string;
client_id: string;
scope: string;
}
export function browserSignInAvailable(): boolean {
return !!getDesktopBridge()?.auth;
}
export async function runDesktopBrowserSignIn(options: {
serverUrl: string;
callbackPath: string;
slot: number;
remember: boolean;
addAccount: boolean;
}): Promise<DesktopBrowserSignInOutcome> {
const bridge = getDesktopBridge();
if (!bridge?.auth) return { kind: 'unavailable' };
const { serverUrl, callbackPath, slot, remember, addAccount } = options;
const prepared = await bridge.servers?.prepare(serverUrl);
if (prepared && !prepared.ok) return { kind: 'declined' };
const { flowId, redirectUri } = await bridge.auth.beginBrowserSignIn();
try {
const res = await apiFetch('/api/auth/desktop-oauth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverUrl, redirectUri }),
});
if (!res.ok) {
bridge.auth.cancelBrowserSignIn(flowId);
return { kind: 'unavailable' };
}
const answer = (await res.json()) as DesktopOAuthAnswer;
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = generateState();
const authorizeUrl = new URL(answer.authorization_endpoint);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', answer.client_id);
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
authorizeUrl.searchParams.set('scope', answer.scope);
authorizeUrl.searchParams.set('state', state);
authorizeUrl.searchParams.set('code_challenge', challenge);
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
if (addAccount) authorizeUrl.searchParams.set('prompt', 'select_account');
const result = await bridge.auth.completeBrowserSignIn(flowId, authorizeUrl.toString());
if (!result.ok) {
return result.error === 'cancelled' ? { kind: 'cancelled' } : { kind: 'failed', error: result.error };
}
if (result.state !== state) return { kind: 'failed', error: 'state_mismatch' };
// What the callback page expects from a same-window flow, plus the two
// values only this flow has.
sessionStorage.setItem('oauth_code_verifier', verifier);
sessionStorage.setItem('oauth_state', state);
sessionStorage.setItem('oauth_server_url', serverUrl);
sessionStorage.removeItem('oauth_server_id');
sessionStorage.setItem('oauth_remember', String(remember));
sessionStorage.setItem('oauth_cookie_slot', String(slot));
sessionStorage.setItem(DESKTOP_REDIRECT_URI_KEY, redirectUri);
sessionStorage.setItem(DESKTOP_CLIENT_ID_KEY, answer.client_id);
if (addAccount) sessionStorage.setItem('oauth_add_account_mode', 'true');
const query = new URLSearchParams({ code: result.code, state: result.state });
return { kind: 'callback', callbackUrl: `${callbackPath}?${query}` };
} catch (error) {
bridge.auth.cancelBrowserSignIn(flowId);
return { kind: 'failed', error: error instanceof Error ? error.message : String(error) };
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* The desktop app's own webmail build (`npm run build:desktop`).
*
* A build-time constant, inlined through next.config.ts `env` into client and
* server bundles alike, like `IS_LITE`. A server admin can't switch it on: in
* any other build the value is compiled in as empty, and the bundler drops
* the branches. Never read this flag through a runtime environment variable.
*
* What it unlocks, and only in that build:
* - login routes accept the servers the user approved in the app
* (`isDesktopTrustedServer`, the list comes from the Electron main process)
* - sign-in through the system browser for any server
* - a per-slot record of the upstream for refresh tokens
* - a login budget per mail server rather than per client address
* - pairing and hand-off routes are gone
*/
export const IS_DESKTOP_BUILD = process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1';
+55
View File
@@ -0,0 +1,55 @@
/**
* Where the page reaches a plain http mail server in the desktop app.
*
* The app's page is https, so Chromium blocks a fetch to a plain http address as
* mixed content, whatever the CSP says. The embedded server forwards
* `/__jmap/<id>/...` to servers the user approved (src/server/jmap-proxy.ts in
* the desktop repo), where the id is the base64url of the server's origin. The
* real URL stays in the account registry and the cookies, since the server
* routes reach the mail server directly: only the page's own requests move.
*/
import { IS_DESKTOP_BUILD } from './flag';
export function desktopJmapOriginId(origin: string): string {
const bytes = new TextEncoder().encode(origin);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/**
* The base URL the page uses to talk to `serverUrl`: the same URL unless this
* is the desktop build and the server speaks plain http.
*/
export function desktopJmapBase(serverUrl: string): string {
if (!IS_DESKTOP_BUILD || typeof window === 'undefined') return serverUrl;
try {
const url = new URL(serverUrl);
if (url.protocol !== 'http:') return serverUrl;
const path = url.pathname.replace(/\/+$/, '');
return `${window.location.origin}/__jmap/${desktopJmapOriginId(url.origin)}${path}`;
} catch {
return serverUrl;
}
}
/** True for a server the page reaches through the embedded server. */
export function isDesktopProxiedServer(serverUrl: string): boolean {
return desktopJmapBase(serverUrl) !== serverUrl;
}
/**
* The route answers everything but JSON as octet-stream, so that nothing can render
* on the app's origin. A blob for the page gets back the type it asked for.
*/
export function withDesktopBlobType(blob: Blob, serverUrl: string, type: string | undefined): Blob {
return type && isDesktopProxiedServer(serverUrl) ? new Blob([blob], { type }) : blob;
}
/** The origin part of {@link desktopJmapBase}, onto which session URLs are moved. */
export function desktopJmapOrigin(serverUrl: string): string {
const { origin, protocol } = new URL(serverUrl);
if (!IS_DESKTOP_BUILD || typeof window === 'undefined' || protocol !== 'http:') return origin;
return `${window.location.origin}/__jmap/${desktopJmapOriginId(origin)}`;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* The key the sign-in routes budget failures on, in the desktop build.
*
* There's one client on the embedded server, so a client key would make every
* account on the machine share five failures per 15 minutes: a stale password
* on one server would lock sign-in on the others. What the budget protects is
* the mail server's failure allowance for this address, so it counts per
* server. The value is no IP address, so forwardedClientHeaders passes nothing
* upstream.
*/
export function desktopLoginBudgetKey(serverUrl: unknown): string {
try {
return `server:${new URL(String(serverUrl)).origin}`;
} catch {
return 'server:invalid';
}
}
+33
View File
@@ -0,0 +1,33 @@
/**
* The mail servers the user approved in the desktop app.
*
* The Electron main process owns this list and sends it to the embedded server
* at start and on every change (src/server/host.ts in the desktop repo sets it
* on globalThis, which all of Next's bundles share). Nothing in the renderer
* or the environment can write it, so script in a page can't aim the login
* routes at the LAN.
*
* Callers guard this with IS_DESKTOP_BUILD, so a server build compiles the
* whole thing away. scripts/standalone-guard.mjs checks that it did.
*/
type TrustedOrigins = ReadonlySet<string>;
function originOf(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
if (parsed.username || parsed.password) return null;
return parsed.origin;
} catch {
return null;
}
}
export function isDesktopTrustedServer(url: string | null | undefined): boolean {
if (!url) return false;
const origin = originOf(url);
if (!origin) return false;
const holder = globalThis as unknown as Record<symbol, TrustedOrigins | undefined>;
return holder[Symbol.for('bulwark.desktop.trustedJmapOrigins')]?.has(origin) === true;
}
+132
View File
@@ -0,0 +1,132 @@
/**
* The desktop app's preload bridge as the webmail sees it. Feature checks ask
* `window.bulwarkDesktop?.<area>`; in a browser the object isn't there.
*
* The shell side lives in the desktop repo (src/shared/bridge.ts), a copy of
* this contract. Add a member here and there together. A breaking change bumps
* `version`; adding a member doesn't.
*/
export const DESKTOP_BRIDGE_VERSION = 1;
export type DesktopWindowType = 'main' | 'message' | 'composer' | 'settings';
export interface DesktopWindowInfo {
id: number;
type: DesktopWindowType;
title: string;
focused: boolean;
}
export interface DesktopWindowsBridge {
/**
* Opens a pop-out window itself. The name is the window's identity: opening
* the same name again focuses the window that has it. Left out, the page uses
* window.open with a features string, which the shell's window-open handler
* sees too.
*/
open?: (url: string, options: { name: string; width: number; height: number }) => void;
/** One window per message: opening it again focuses it. */
openMessage?: (target: { url: string; emailId: string }) => void;
openComposer?: (target: { url: string; name?: string }) => void;
/** Focuses a window from `list()`, or the main window with no id. */
focus?: (id?: number) => void;
list?: () => Promise<DesktopWindowInfo[]>;
/** Fires when a window opens, closes, retitles or takes the focus. Returns the unsubscribe. */
onChanged?: (listener: (windows: DesktopWindowInfo[]) => void) => () => void;
}
export interface DesktopThemeBridge {
/** The page's colours, for title bar buttons and the first frame of new windows. */
set: (theme: { dark: boolean; background: string; foreground?: string }) => void;
/** Fires when the OS switches between light and dark. Returns the unsubscribe. */
onSystemChange?: (listener: (system: { dark: boolean; highContrast: boolean }) => void) => () => void;
}
export interface DesktopMenuBridge {
/**
* What the app menu shows as enabled or checked, by command id. The shell
* keeps the last state per window and applies it when that window has focus.
*/
setState: (state: Record<string, boolean | { enabled?: boolean; checked?: boolean }>) => void;
}
export interface DesktopShellBridge {
/** http, https and mailto only. */
openExternal: (url: string) => void;
}
export type DesktopServerPrepareResult =
| { ok: true; approved: boolean }
| { ok: false; reason: 'declined' | 'invalid' };
export interface DesktopServersBridge {
/**
* Call before signing in to a server. Asks the user first when it is on the
* LAN or plain http, and lets the app reach it. Standalone mode only.
*/
prepare: (serverUrl: string) => Promise<DesktopServerPrepareResult>;
/**
* The sign-in worked, so the server stays: what `prepare` added lasts only until
* the app quits before this. Other origins a session names get a prompt.
*/
noteSession: (
serverUrl: string,
urls: { apiUrl?: string; downloadUrl?: string; uploadUrl?: string; eventSourceUrl?: string },
) => void;
/** A sign-in that failed, or the last account on this server signing out. */
forget?: (serverUrl: string, reason: 'failed' | 'signed-out') => void;
}
export interface DesktopLifecycleBridge {
/**
* Before a window closes or the app quits, the shell asks the page to save what
* it has and waits a few seconds. A window with no handler closes at once.
*/
onFlushRequest: (handler: () => Promise<unknown>) => () => void;
}
export type DesktopBrowserSignInResult =
| { ok: true; code: string; state: string }
| { ok: false; error: string };
export interface DesktopAuthBridge {
/** Starts a short-lived loopback listener for one sign-in. */
beginBrowserSignIn: () => Promise<{ flowId: string; redirectUri: string }>;
/** Opens the system browser on `authorizeUrl` and waits for the redirect. */
completeBrowserSignIn: (flowId: string, authorizeUrl: string) => Promise<DesktopBrowserSignInResult>;
cancelBrowserSignIn: (flowId: string) => void;
}
export type DesktopPluginInstallResult =
| { ok: true; id: string; version: string }
| { ok: false; error: string; code?: string };
export interface DesktopPluginsBridge {
/**
* Installs from the directory through the local admin API, with its signature
* and hash checks, after a native prompt. No version means the latest. The
* renderer never holds an admin session. Standalone only.
*/
install: (slug: string, version?: string) => Promise<DesktopPluginInstallResult>;
}
export interface BulwarkDesktopBridge {
version: number;
platform: 'win32' | 'darwin' | 'linux';
mode?: 'standalone' | 'instance';
windows?: DesktopWindowsBridge;
theme?: DesktopThemeBridge;
menu?: DesktopMenuBridge;
shell?: DesktopShellBridge;
lifecycle?: DesktopLifecycleBridge;
servers?: DesktopServersBridge;
auth?: DesktopAuthBridge;
plugins?: DesktopPluginsBridge;
}
declare global {
interface Window {
bulwarkDesktop?: BulwarkDesktopBridge;
}
}
+42
View File
@@ -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);
}

Some files were not shown because too many files have changed in this diff Show More