chore: merge the reviewed store, pop-outs and desktop core into the settings branch

Eviction now goes by the base's signal: `storeLost` and the `store-reset`
notice, with `bulwark-store-opened` as the marker and `prune-files` for files
with no key. The storage page and the lost count hang off that. What a user
picks is stored under `bulwark-offline-scope`, the key `offlineScopeFor`
reads, and `email-snapshot` follows the base's rule. `OfflineEnabled` is read
from the desktop bridge when it's there.
This commit is contained in:
Linus Rath
2026-11-03 10:40:47 +01:00
201 changed files with 11501 additions and 709 deletions
+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);
}
+43 -5
View File
@@ -1,4 +1,6 @@
import { recordJmapCall, reportJmapFailure } from "@/lib/error-reporting/client";
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { desktopJmapBase, desktopJmapOrigin, isDesktopProxiedServer, withDesktopBlobType } from '@/lib/desktop/jmap-route';
import { advertisesMailboxMetadata, bulwarkEntries, escapeMetadataPathSegment, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataPatchError, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
import { IncompleteDeleteError } from "./delete-error";
import { downloadAllowed, saveBlobAs, type DownloadKind } from "@/lib/download-guard";
@@ -1690,6 +1692,25 @@ export class JMAPClient implements IJMAPClient {
return client;
}
/**
* A client on a credential another window of this browser holds (a pop-out,
* lib/popout): the header as that window sends it, whatever kind it is.
*/
static withSharedAuth(
serverUrl: string,
username: string,
authHeader: string,
onTokenRefresh?: () => Promise<string | null>,
): JMAPClient {
const client = new JMAPClient(serverUrl, username, '');
client.authHeader = authHeader;
if (authHeader.startsWith('Bearer ')) {
client.authMode = 'bearer';
client.onTokenRefresh = onTokenRefresh;
}
return client;
}
updateAccessToken(token: string): void {
this.authHeader = `Bearer ${token}`;
}
@@ -1894,7 +1915,7 @@ export class JMAPClient implements IJMAPClient {
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
response = await this.timedFetch(url, init, retryHeaders, timeoutMs);
}
} else if (this.authMode === 'basic' && !this.reconnecting && url !== `${this.serverUrl}/.well-known/jmap`) {
} else if (this.authMode === 'basic' && !this.reconnecting && url !== this.discoveryUrl()) {
// JMAP session may have expired - re-establish and retry once
this.reconnecting = true;
try {
@@ -1944,7 +1965,7 @@ export class JMAPClient implements IJMAPClient {
* and retry directly against the final URL so we can re-send Authorization.
*/
private async fetchSessionResponse(): Promise<Response> {
const discoveryUrl = `${this.serverUrl}/.well-known/jmap`;
const discoveryUrl = this.discoveryUrl();
const response = await this.authenticatedFetch(discoveryUrl, { method: 'GET' });
// A proxy in front of the session URL answers the header-less redirected
// request with 401 instead of Stalwart's silent 200 - the credentials are
@@ -1987,7 +2008,7 @@ export class JMAPClient implements IJMAPClient {
async connect(): Promise<void> {
if (this.offlineOnly) throw new OfflineUnavailableError();
this.intentionallyDisconnected = false;
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
const sessionUrl = this.discoveryUrl();
try {
const sessionResponse = await this.fetchSessionResponse();
@@ -2144,10 +2165,15 @@ export class JMAPClient implements IJMAPClient {
this.capabilities = {};
}
/** Same as serverUrl, except for a plain http server in the desktop app (lib/desktop/jmap-route.ts). */
private discoveryUrl(): string {
return `${desktopJmapBase(this.serverUrl)}/.well-known/jmap`;
}
private rewriteSessionUrl(url: string): string {
if (!url) return url;
try {
const { origin } = new URL(this.serverUrl);
const origin = desktopJmapOrigin(this.serverUrl);
if (!/^(https?:)?\/\//i.test(url)) {
return origin + (url.startsWith('/') ? url : '/' + url);
}
@@ -2180,6 +2206,14 @@ export class JMAPClient implements IJMAPClient {
if (session.eventSourceUrl) {
session.eventSourceUrl = rewrite(session.eventSourceUrl);
}
// A download or upload origin of its own needs the CORS headers the desktop
// app adds for this account's servers.
getDesktopBridge()?.servers?.noteSession(this.serverUrl, {
apiUrl: session.apiUrl,
downloadUrl: session.downloadUrl,
uploadUrl: session.uploadUrl,
eventSourceUrl: session.eventSourceUrl,
});
}
/**
@@ -6508,7 +6542,7 @@ export class JMAPClient implements IJMAPClient {
if (!response.ok) {
throw new Error(`Failed to fetch blob: ${response.status}`);
}
const blob = await response.blob();
const blob = withDesktopBlobType(await response.blob(), this.serverUrl, type);
getOfflineHooks()?.recordBlob(this, blobId, accountId || this.accountId, blob);
return blob;
}
@@ -6992,6 +7026,10 @@ export class JMAPClient implements IJMAPClient {
getEventSourceUrl(): string | null {
if (!this.session) return null;
// A plain http server in the desktop app shares the app's own origin, on
// http/1.1 and six sockets, with every window. Push streams pinning those
// would stall the app, so these accounts poll (setupPushNotifications).
if (isDesktopProxiedServer(this.serverUrl)) return null;
// RFC 8620: session root level, with fallback to capabilities for some servers
const coreCapability = this.session.capabilities?.["urn:ietf:params:jmap:core"] as { eventSourceUrl?: string } | undefined;
+9
View File
@@ -22,6 +22,8 @@ export interface OfflineClientRef {
getServerUrl(): string;
getUsername(): string;
getAccountId(): string;
/** Answers from this device only: a local copy on screen at a start. */
isOfflineOnly?(): boolean;
}
export interface OfflineHooks {
@@ -86,6 +88,13 @@ export function isNetworkFailure(error: unknown): boolean {
return error.name === 'TypeError' && /fetch|network|load failed|internet/i.test(error.message);
}
/** The connection is fine and a proxy answered: the mail server behind it is what's gone. */
export function isServerOutage(error: unknown): boolean {
if (!(error instanceof Error)) return false;
if (error.name === 'GatewayError') return true;
return error.name === 'SendNotStartedError' && (error as SendNotStartedError).cause instanceof Error && ((error as SendNotStartedError).cause as Error).name === 'GatewayError';
}
/** Thrown for a request the device can't answer while the server can't be reached. */
export class OfflineUnavailableError extends Error {
constructor(message = 'Not available offline') {
+72
View File
@@ -0,0 +1,72 @@
/**
* Which server minted a slot's refresh token, for the desktop build.
*
* The token routes know a server only through `jmap_rts`, the id of an entry in
* `jmapServers`, and otherwise fall back to the configured server. A standalone
* desktop app has neither: every account is a server the user typed. So refresh,
* revocation and sign-out would throw "OAuth misconfigured", and the account
* would first look unreachable and then be signed out.
*
* The record is sealed so only this server can have written it. Whether the
* address may be reached isn't stored: it is checked again on every read, by
* the same rule as the sign-in routes.
*/
import type { cookies } from 'next/headers';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
import { getSignInCookieOptions } from '@/lib/oauth/cookie-config';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
type CookieStore = Awaited<ReturnType<typeof cookies>>;
const COOKIE = 'jmap_rtu';
export interface CustomUpstream {
serverUrl: string;
clientId: string;
/** The user approved this server, so it may be on a private address. */
trusted: boolean;
}
export function customUpstreamCookieName(slot: number): string {
return slot === 0 ? COOKIE : `${COOKIE}_${slot}`;
}
/** Null when the address isn't one the sign-in routes would accept right now. */
export async function resolveCustomUpstream(serverUrl: unknown, clientId: unknown): Promise<CustomUpstream | null> {
if (typeof serverUrl !== 'string' || !serverUrl || typeof clientId !== 'string' || !clientId) return null;
await configManager.ensureLoaded();
const servers = parseJmapServers(configManager.get<unknown>('jmapServers', []));
const trusted = resolveTrustedJmapUrl(serverUrl, configManager.get<string>('jmapServerUrl', ''), servers) !== null;
if (!trusted && !(await isPublicHttpUrl(serverUrl))) return null;
return { serverUrl: serverUrl.replace(/\/+$/, ''), clientId, trusted };
}
export function writeCustomUpstream(
cookieStore: CookieStore,
slot: number,
upstream: Pick<CustomUpstream, 'serverUrl' | 'clientId'>,
remember: boolean,
): void {
const value = encryptPayload({ s: upstream.serverUrl, c: upstream.clientId }, 'custom-upstream');
cookieStore.set(customUpstreamCookieName(slot), value, getSignInCookieOptions(remember));
}
export async function readCustomUpstream(cookieStore: CookieStore, slot: number): Promise<CustomUpstream | null> {
const value = cookieStore.get(customUpstreamCookieName(slot))?.value;
if (!value) return null;
let payload: Record<string, unknown> | null;
try {
payload = decryptPayload(value, 'custom-upstream');
} catch {
return null;
}
return payload ? resolveCustomUpstream(payload.s, payload.c) : null;
}
export function clearCustomUpstream(cookieStore: CookieStore, slot: number): void {
const name = customUpstreamCookieName(slot);
if (cookieStore.get(name)) cookieStore.delete(name);
}
+17 -4
View File
@@ -8,6 +8,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers';
import { stalwartDefaultClientId } from '@/lib/auth/stalwart-password-login';
import { getOauthResource } from '@/lib/oauth/tokens';
import type { CustomUpstream } from '@/lib/oauth/custom-upstream';
// Fallback OAuth client id used when no client is configured. The password+TOTP
// login route mints tokens against the mail server's built-in OAuth with this
@@ -25,6 +26,12 @@ export interface ClientConfigOptions {
* so a misconfiguration surfaces at login rather than as a broken session.
*/
fallbackClientId?: string;
/**
* The server this token belongs to when it's in no configured list: the
* desktop app's accounts (lib/oauth/custom-upstream.ts). Wins over `serverId`
* and the global server, and the configured client secret is never sent to it.
*/
upstream?: CustomUpstream | null;
}
function httpOrigin(url: string): string | null {
@@ -101,6 +108,10 @@ function discoveryBase(url: string): string {
}
export function getRequiredConfig(serverId?: string | null, options?: ClientConfigOptions) {
if (options?.upstream) {
const { clientId, serverUrl } = options.upstream;
return { clientId, serverUrl, discoveryUrl: discoveryBase(serverUrl), serverId: undefined, endSessionUrl: undefined, resource: undefined };
}
const entry = getServerEntry(serverId);
const globalClientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
@@ -139,7 +150,8 @@ export function getRequiredConfig(serverId?: string | null, options?: ClientConf
* with a client it registers itself (lib/auth/stalwart-password-login.ts),
* whose id the refresh must present instead of the default one.
*/
export async function defaultClientOptions(serverId?: string | null): Promise<ClientConfigOptions> {
export async function defaultClientOptions(serverId?: string | null, upstream?: CustomUpstream | null): Promise<ClientConfigOptions> {
if (upstream) return { upstream };
const { clientId, serverUrl } = getRequiredConfig(serverId, { fallbackClientId: DEFAULT_CLIENT_ID });
if (clientId !== DEFAULT_CLIENT_ID) return { fallbackClientId: DEFAULT_CLIENT_ID };
return { fallbackClientId: await stalwartDefaultClientId(serverUrl, DEFAULT_CLIENT_ID, true) };
@@ -177,7 +189,7 @@ export async function getMetadata(serverId?: string | null, options?: ClientConf
export function buildOAuthParams(base: Record<string, string>, serverId?: string | null, options?: ClientConfigOptions): URLSearchParams {
const { clientId } = getRequiredConfig(serverId, options);
const params = new URLSearchParams({ ...base, client_id: clientId });
const secret = getClientSecret(serverId);
const secret = options?.upstream ? '' : getClientSecret(serverId);
if (secret) {
params.set('client_secret', secret);
}
@@ -197,15 +209,16 @@ export async function exchangeCodeForTokens(
codeVerifier: string,
redirectUri: string,
serverId?: string | null,
options?: ClientConfigOptions,
): Promise<TokenResult> {
const tokenEndpoint = await getTokenEndpoint(serverId);
const tokenEndpoint = await getTokenEndpoint(serverId, options);
const params = buildOAuthParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}, serverId);
}, serverId, options);
// The body carries the code, the PKCE verifier and the client secret. The
// endpoint was validated; a redirect would re-send all of it to a host
@@ -109,7 +109,7 @@ describe('the user picking what to keep', () => {
await store.sync('acc');
chooseScope('acc', { days: 30 });
await vi.waitFor(() => expect(ids()).toEqual(['new']));
expect(JSON.parse(localStorage.getItem('bulwark-offline-choices')!).acc.days).toBe(30);
expect(JSON.parse(localStorage.getItem('bulwark-offline-scope')!).acc.days).toBe(30);
});
it('can\'t get past the cap', () => {
@@ -150,23 +150,22 @@ describe('clearing an account', () => {
expect(store.removed).toContain('acc');
expect(useOfflineStore.getState().enabled).toEqual(['acc']);
expect(store.dbs.get('acc')).not.toBe(before);
expect(useOfflineStore.getState().health.acc?.opened).toBe(false);
// It isn't read as a browser clearing the store at the next open.
expect(notices.filter((n) => n.kind === 'evicted')).toEqual([]);
expect(useOfflineStore.getState().health.acc?.clearedAt).toBeGreaterThan(0);
expect(notices.filter((n) => n.kind === 'store-reset')).toEqual([]);
});
});
describe('clearing in another tab', () => {
it('opens the new store here, in case this tab owns it', async () => {
writeEnabled(['acc']);
writeHealth('acc', { opened: true });
writeHealth('acc', { clearedAt: 1 });
const stop = startOfflineMode();
const ensureOpen = vi.spyOn(store, 'ensureOpen');
ensureOpen.mockClear();
localStorage.setItem(OFFLINE_HEALTH_KEY, JSON.stringify({ acc: { opened: false, pending: 0 } }));
localStorage.setItem(OFFLINE_HEALTH_KEY, JSON.stringify({ acc: { clearedAt: 2, pending: 0 } }));
window.dispatchEvent(new StorageEvent('storage', { key: OFFLINE_HEALTH_KEY }));
expect(ensureOpen).toHaveBeenCalledWith('acc');
expect(useOfflineStore.getState().health.acc.opened).toBe(false);
expect(useOfflineStore.getState().health.acc.clearedAt).toBe(2);
stop();
});
});
+18 -2
View File
@@ -6,7 +6,7 @@ import { forgetLegacyMove, LEGACY_DB_NAME, moveLegacyOutbox } from '../migrate-l
import { readQueue } from '../queue';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { effectiveScope, platformDefault, userChoiceFor } from '../scope';
import { OFFLINE_CHOICES_KEY, useOfflineStore, writeChoice } from '../state';
import { OFFLINE_SCOPE_KEY, useOfflineStore, writeChoice } from '../state';
/** The old database, exactly as lib/offline/db.ts laid it out. */
const STORES = ['keys', 'emails', 'bodies', 'blobs', 'misc', 'ledger', 'queue', 'uploads'];
@@ -87,6 +87,22 @@ afterEach(() => {
});
describe('moving the old outbox into the store', () => {
it('gives a sender written "Name <address>" a Message-ID with a clean domain', async () => {
await seedOld();
const db = await new Promise<IDBDatabase>((resolve) => {
const request = indexedDB.open(LEGACY_DB_NAME);
request.onsuccess = () => resolve(request.result);
});
await put(db, 'queue', {
id: 20, account: 'acc', kind: 'send', createdAt: 20, status: 'waiting',
...(await seal({ summary: { subject: 'Named', to: [] }, item: { kind: 'email', args: [[], 'Named', 'x', null, null, 'i', 'Alice <a@example.org>'] } })),
});
db.close();
await moveLegacyOutbox();
const named = (await readQueue('acc')).outbox.find((entry) => entry.summary.subject === 'Named');
expect(named?.messageId).toMatch(/@example\.org$/);
});
it('moves everything that can\'t be fetched again, then deletes the old database', async () => {
await seedOld();
await moveLegacyOutbox();
@@ -165,7 +181,7 @@ describe('moving the old outbox into the store', () => {
const state = useOfflineStore.getState();
expect(state.enabled).toContain('acc');
expect(state.choices.acc).toMatchObject({ on: true, days: 30 });
expect(JSON.parse(localStorage.getItem(OFFLINE_CHOICES_KEY)!).acc.days).toBe(30);
expect(JSON.parse(localStorage.getItem(OFFLINE_SCOPE_KEY)!).acc.days).toBe(30);
expect(effectiveScope('acc', userChoiceFor('acc'), DEFAULT_ORGANISATION_POLICY.devices, platformDefault()).scope.days).toBe(30);
});
+2
View File
@@ -264,6 +264,8 @@ describe('a send whose response got lost', () => {
const entry = (await readQueue('acc')).outbox[0];
await store.area('acc', 'outbox.claim', { id: entry.id });
await store.area('acc', 'outbox.update', { id: entry.id, status: 'uncertain' });
// The wait before a lookup has passed.
store.dbs.get('acc')!.run('UPDATE outbox SET last_attempt_at = ?', [Date.now() - 31_000]);
return entry;
}
+392 -4
View File
@@ -19,17 +19,25 @@ import {
confirmLoss,
enforcePolicy,
offlineSessionFor,
offlineSupported,
pluginsNotRunning,
refreshQueueState,
setOfflineNotifier,
startOfflineMode,
stricterNow,
unsentCount,
wipeEverything,
} from '../offline-mode';
import { flushQueue, settleStaleSends, SENDING_STALE_MS } from '../flush';
import { claimForSending, holdFile, queueSend, readQueue } from '../queue';
import { forgetLegacyMove } from '../migrate-legacy';
import { flushQueue, forgetOrphanCheck, LOOKUP_WAIT_MS, ORPHAN_FILE_AGE_MS, settleStaleSends, SENDING_STALE_MS } from '../flush';
import { installMayPass } from '@/lib/store/adapters/wasm';
import { isServerOutage, SendNotStartedError } from '@/lib/jmap/offline-hooks';
import { claimForSending, holdFile, LOCAL_BLOB_PREFIX, queueSend, readQueue, updateOutboxEntry } from '../queue';
import { forgetLegacyMove, strandedLegacyMessages } from '../migrate-legacy';
import { consumeLost } from '../lost';
import { getOutboxHooks } from '../outbox-hooks';
import { shellFiles } from '../shell';
import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, setServerDown, useOfflineStore, writeEnabled } from '../state';
import { DEFAULT_SCOPE } from '@/lib/store/area';
import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, OFFLINE_EVICTED_KEY, OFFLINE_SCOPE_KEY, offlineScopeFor, setServerDown, useOfflineStore, writeEnabled } from '../state';
function fakeClient(server = 'https://mail.example.org', overrides: Record<string, unknown> = {}) {
return {
@@ -56,6 +64,7 @@ function setOnline(online: boolean) {
}
let stop: () => void = () => {};
const useRealClient = useAuthStore.getState().getClientForAccount;
let store: InProcessStore;
beforeEach(async () => {
@@ -283,6 +292,385 @@ describe('11: starting from the device', () => {
writeEnabled(['acc']);
expect(await offlineSessionFor('acc')).toMatchObject({ primaryAccounts: { 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT } });
});
it('gives up on a policy fetch that never answers', async () => {
writeEnabled(['acc']);
usePolicyStore.setState({ loaded: false, fetching: false, source: 'none' });
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {})));
try {
const session = offlineSessionFor('acc');
await vi.advanceTimersByTimeAsync(10_000);
expect(await session).toBeNull();
} finally {
vi.unstubAllGlobals();
vi.useRealTimers();
}
});
it('doesn\'t wait for the old database to finish moving', async () => {
writeEnabled(['acc']);
forgetLegacyMove();
const legacy = indexedDB.open;
vi.spyOn(indexedDB, 'open').mockImplementation(((name: string, version?: number) => {
// A database that never opens.
if (name === 'bulwark-offline') return { result: null } as never;
return legacy.call(indexedDB, name, version);
}) as never);
vi.useFakeTimers();
try {
const session = offlineSessionFor('acc');
await vi.advanceTimersByTimeAsync(10_000);
expect(await session).toMatchObject({ primaryAccounts: { 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT } });
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
});
it('doesn\'t offer a quick start before the store has its folders', async () => {
store = new InProcessStore(new FakeJmapMail());
setStoreBackend(store);
writeEnabled(['acc']);
expect(await offlineSessionFor('acc', { withFolders: true })).toBeNull();
await store.sync('acc');
expect(await offlineSessionFor('acc', { withFolders: true })).toMatchObject({ primaryAccounts: { 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT } });
});
});
describe('what a sign-out would delete', () => {
beforeEach(() => writeEnabled(['acc', 'other']));
const addCalendarEdit = async (account: string) => {
await store.ensureOpen(account);
store.dbs.get(account)!.run("INSERT INTO calendar_pending (jmap, record_id, kind, patch, queued_at) VALUES ('j1', 'ev', 'update', '{}', 1)");
};
it('counts the queue itself, not what the screen last saw', async () => {
await queueSend('acc', { subject: 'A', to: [] }, { kind: 'email', args: [] });
await queueSend('other', { subject: 'B', to: [] }, { kind: 'email', args: [] });
expect(useOfflineStore.getState().outbox).toEqual([]);
expect(await unsentCount('acc')).toEqual({ messages: 1, changes: 0 });
expect(await unsentCount('all')).toEqual({ messages: 2, changes: 0 });
});
it('counts changes and calendar edits, for the account asked about', async () => {
await addCalendarEdit('acc');
await store.area('acc', 'outbox.queueSend', { summary: { subject: 'A', to: [] }, item: { kind: 'email', args: [] } });
expect(await unsentCount('other')).toEqual({ messages: 0, changes: 0 });
expect(await unsentCount('acc')).toEqual({ messages: 1, changes: 1 });
});
it('counts an area that isn\'t here as nothing', async () => {
store.failArea = (name) => name === 'contacts.pending';
await addCalendarEdit('acc');
expect((await unsentCount('acc')).changes).toBe(1);
});
it('answers with the last known count when the store hangs', async () => {
useOfflineStore.setState({ outbox: [{ id: 1, account: 'acc', createdAt: 1, subject: 'A', to: [], status: 'waiting' }] as never, pendingChanges: 2 });
const hung: Array<(error: Error) => void> = [];
vi.spyOn(store, 'area').mockImplementation(() => new Promise((_resolve, reject) => { hung.push(reject); }));
expect(await unsentCount('acc', 10)).toEqual({ messages: 1, changes: 2 });
for (const reject of hung) reject(new Error('Gone'));
});
it('doesn\'t count another signed-out account\'s waiting mail', async () => {
await queueSend('other', { subject: 'B', to: [] }, { kind: 'email', args: [] });
localStorage.setItem(OFFLINE_EVICTED_KEY, JSON.stringify({ other: Date.now() }));
expect(await unsentCount('all')).toEqual({ messages: 0, changes: 0 });
});
});
describe('a store that won\'t open', () => {
beforeEach(() => writeEnabled(['acc', 'other']));
it('holds up no other account, and keeps what it last showed', async () => {
await queueSend('acc', { subject: 'A', to: [] }, { kind: 'email', args: [] });
await queueSend('other', { subject: 'B', to: [] }, { kind: 'email', args: [] });
await refreshQueueState();
const area = store.area.bind(store);
vi.spyOn(store, 'area').mockImplementation(((account: string, name: string, params: unknown) => (account === 'other' ? Promise.reject(new Error('The local store could not start')) : area(account, name, params))) as never);
const notify = vi.fn();
setOfflineNotifier(notify);
await refreshQueueState();
expect(useOfflineStore.getState().outbox.map((row) => row.subject).sort()).toEqual(['A', 'B']);
expect(notify).toHaveBeenCalledWith({ kind: 'store-unavailable' });
// Not again right away.
notify.mockClear();
await refreshQueueState();
expect(notify).not.toHaveBeenCalled();
});
it('doesn\'t call an unreadable queue empty', async () => {
await queueSend('acc', { subject: 'A', to: [] }, { kind: 'email', args: [] });
await refreshQueueState();
vi.spyOn(store, 'area').mockRejectedValue(new Error('The local store could not start'));
await refreshQueueState();
expect(useOfflineStore.getState().outbox.map((row) => row.subject)).toEqual(['A']);
await expect(readQueue()).rejects.toThrow('could not start');
});
});
describe('a send the server couldn\'t take right now', () => {
const statusAfter = async (error: Error) => {
writeEnabled(['acc']);
await queueSend('acc', { subject: 'Hello', to: [] }, { kind: 'email', args: [] });
const notify = vi.fn();
const retryLater = vi.fn();
const sendEmail = vi.fn(async () => { throw error; });
await flushQueue('acc', fakeClient('https://mail.example.org', { sendEmail }) as unknown as JMAPClient, { notify, allowed: () => true, retryLater });
const row = (await readQueue('acc')).outbox[0];
return { row, notify, retryLater };
};
it.each([
['a rate limit', Object.assign(new Error('Rate limited by server'), { name: 'RateLimitError' })],
['a login that needs renewing', new Error('Request failed: 401 - ')],
['a store that went away', Object.assign(new Error('gone'), { name: 'StoreOwnerGoneError' })],
['a full disk', Object.assign(new Error('full'), { name: 'QuotaExceededError' })],
])('goes back to waiting, on a timer, after %s', async (_name, error) => {
const { row, notify, retryLater } = await statusAfter(error);
expect(row.status).toBe('waiting');
expect(retryLater).toHaveBeenCalled();
expect(notify).not.toHaveBeenCalled();
});
it.each(['Request failed: 500 - oops', 'Request failed: 501 - nope'])('is looked up after %s, which may have got through', async (message) => {
const { row, notify, retryLater } = await statusAfter(new Error(message));
expect(row.status).toBe('uncertain');
expect(retryLater).toHaveBeenCalled();
expect(notify).not.toHaveBeenCalled();
});
it('fails, for the user to see, on a refusal', async () => {
const { row, notify } = await statusAfter(new Error('Request failed: 400 - {"type":"urn:ietf:params:jmap:error:notRequest"}'));
expect(row.status).toBe('failed');
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ kind: 'send-failed' }));
});
it('keeps the copy on the server through a wait', async () => {
writeEnabled(['acc']);
const id = await queueSend('acc', { subject: 'Hello', to: [] }, { kind: 'email', args: [] });
const [entry] = (await readQueue('acc')).outbox;
await updateOutboxEntry(entry, { status: 'waiting', copyId: 'copy-1' });
const sendEmail = vi.fn(async () => { throw new Error('Request failed: 429 - '); });
await flushQueue('acc', fakeClient('https://mail.example.org', { sendEmail }) as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
const row = (await readQueue('acc')).outbox.find((candidate) => candidate.id === id);
expect(row).toMatchObject({ status: 'waiting', copyId: 'copy-1' });
});
it('doesn\'t hand an online send a stand-in for a file the upload held again', async () => {
writeEnabled(['acc']);
stop = startOfflineMode();
const { blobId } = await holdFile('acc', new File(['x'], 'x.txt', { type: 'text/plain' }), 'x.txt');
const client = fakeClient('https://mail.example.org', { uploadBlob: vi.fn(async () => ({ blobId: `${LOCAL_BLOB_PREFIX}stand-in`, size: 1, type: 'text/plain' })) });
useAuthStore.setState({ client: client as never, activeAccountId: 'acc', getClientForAccount: () => client } as never);
try {
await expect(getOutboxHooks()!.resolveHeldAttachments(client as never, [{ blobId }])).rejects.toMatchObject({ name: 'SendNotStartedError' });
} finally {
useAuthStore.setState({ getClientForAccount: useRealClient, client: null });
}
});
it('doesn\'t send a stand-in for a file the upload held again', async () => {
writeEnabled(['acc']);
const { blobId } = await holdFile('acc', new File(['x'], 'x.txt', { type: 'text/plain' }), 'x.txt');
await queueSend('acc', { subject: 'Hello', to: [] }, { kind: 'email', args: [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, [{ blobId, name: 'x.txt', type: 'text/plain', size: 1 }]] });
const sendEmail = vi.fn(async () => ({ scheduled: false, emailId: 'e', emailSubmissionId: 's' }));
const uploadBlob = vi.fn(async () => ({ blobId: `${LOCAL_BLOB_PREFIX}stand-in`, size: 1, type: 'text/plain' }));
await flushQueue('acc', fakeClient('https://mail.example.org', { sendEmail, uploadBlob }) as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
expect(sendEmail).not.toHaveBeenCalled();
expect((await readQueue('acc')).outbox[0].status).toBe('waiting');
});
});
describe('waiting mail after a normal start', () => {
const realFetchPolicy = usePolicyStore.getState().fetchPolicy;
beforeEach(() => {
vi.useFakeTimers();
// rawPolicy: what the policy is worked out from again when the subject changes.
usePolicyStore.setState({ fetchPolicy: vi.fn(async () => {}), source: 'server', rawPolicy: usePolicyStore.getState().policy } as never);
useOfflineStore.setState({ downServers: [] });
writeEnabled(['acc']);
});
afterEach(() => {
vi.useRealTimers();
usePolicyStore.setState({ fetchPolicy: realFetchPolicy });
useOfflineStore.setState({ downServers: [] });
});
it('goes when the client becomes real after the flush at the start', async () => {
await queueSend('acc', { subject: 'Hello', to: [] }, { kind: 'email', args: [] });
stop = startOfflineMode();
// The flush at the start finds no client yet.
await vi.advanceTimersByTimeAsync(5_000);
const client = fakeClient();
useAuthStore.setState({ getClientForAccount: () => client, client: client as never } as never);
try {
await vi.advanceTimersByTimeAsync(1_500);
expect(client.sendEmail).toHaveBeenCalledTimes(1);
} finally {
useAuthStore.setState({ getClientForAccount: useRealClient, client: null });
}
});
it('comes back to a send a closed tab left behind', async () => {
const id = await queueSend('acc', { subject: 'Hello', to: [] }, { kind: 'email', args: [] });
await claimForSending('acc', id);
stop = startOfflineMode();
await vi.advanceTimersByTimeAsync(5_000);
expect((await readQueue('acc')).outbox[0].status).toBe('sending');
await vi.advanceTimersByTimeAsync(SENDING_STALE_MS);
expect((await readQueue('acc')).outbox[0].status).toBe('uncertain');
});
it('asks a server that was down, on an interval, and flushes when it answers', async () => {
const fetcher = vi.fn(async (_url: string) => new Response(null, { status: 200 }));
vi.stubGlobal('fetch', fetcher);
try {
stop = startOfflineMode();
await vi.advanceTimersByTimeAsync(5_000);
setServerDown('https://mail.example.org', true);
await vi.advanceTimersByTimeAsync(30_000);
expect(fetcher.mock.calls.map(([url]) => String(url))).toContain('https://mail.example.org/.well-known/jmap');
expect(isOfflineNow('https://mail.example.org')).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});
});
describe('old files', () => {
it('go after a flush, once a day, not a file that\'s still recent', async () => {
forgetOrphanCheck();
writeEnabled(['acc']);
const { blobId } = await holdFile('acc', new File(['x'], 'x.txt', { type: 'text/plain' }), 'x.txt');
const client = fakeClient() as unknown as JMAPClient;
const flush = () => flushQueue('acc', client, { notify: vi.fn(), allowed: () => true });
await flush();
expect(await store.area('acc', 'outbox.heldFile', { blobId })).not.toBeNull();
const area = vi.spyOn(store, 'area');
await flush();
expect(area.mock.calls.some(([, name]) => name === 'outbox.dropOrphans')).toBe(false);
const later = Date.now() + ORPHAN_FILE_AGE_MS + 1000;
const clock = vi.spyOn(Date, 'now').mockReturnValue(later);
try {
await flush();
expect(area.mock.calls.some(([, name]) => name === 'outbox.dropOrphans')).toBe(true);
expect(await store.area('acc', 'outbox.heldFile', { blobId })).toBeNull();
} finally {
clock.mockRestore();
area.mockRestore();
}
});
});
describe('a browser that can\'t run the store', () => {
it('says so when the old database holds unsent mail', async () => {
await new Promise<void>((resolve) => {
const request = indexedDB.open('bulwark-offline', 1);
request.onupgradeneeded = () => { request.result.createObjectStore('queue', { keyPath: 'id' }); };
request.onsuccess = () => {
const put = request.result.transaction('queue', 'readwrite').objectStore('queue');
put.put({ id: 1, kind: 'patch' });
put.put({ id: 2, kind: 'send' });
put.transaction.oncomplete = () => { request.result.close(); resolve(); };
};
});
expect(await strandedLegacyMessages()).toBe(1);
store.isSupported = false;
const notify = vi.fn();
setOfflineNotifier(notify);
stop = startOfflineMode();
await vi.waitFor(() => expect(notify).toHaveBeenCalledWith({ kind: 'legacy-stranded' }));
store.isSupported = true;
await new Promise<void>((resolve) => {
const remove = indexedDB.deleteDatabase('bulwark-offline');
remove.onsuccess = () => resolve();
});
expect(await strandedLegacyMessages()).toBe(0);
});
it('turns the switch off in a window that refuses the private file system, and wipes nothing', async () => {
writeEnabled(['acc']);
Object.defineProperty(window.navigator, 'storage', { configurable: true, value: { getDirectory: async () => { throw new DOMException('The operation is insecure.', 'SecurityError'); } } });
try {
stop = startOfflineMode();
await vi.waitFor(() => expect(useOfflineStore.getState().storageBlocked).toBe(true));
expect(offlineSupported()).toBe(false);
enforcePolicy();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(useOfflineStore.getState().enabled).toEqual(['acc']);
} finally {
delete (window.navigator as { storage?: unknown }).storage;
useOfflineStore.setState({ storageBlocked: false });
}
});
it('doesn\'t retry the install where the browser refuses it', () => {
expect(installMayPass(new DOMException('The operation is insecure.', 'SecurityError'))).toBe(false);
expect(installMayPass(new DOMException('Handle is busy', 'NoModificationAllowedError'))).toBe(true);
});
});
describe('the wording of an outage', () => {
it('tells a proxy with no server behind it from a missing connection', () => {
const gateway = Object.assign(new Error('Request failed: 502'), { name: 'GatewayError' });
expect(isServerOutage(gateway)).toBe(true);
expect(isServerOutage(new SendNotStartedError(gateway))).toBe(true);
expect(isServerOutage(new SendNotStartedError(new TypeError('Failed to fetch')))).toBe(false);
expect(isServerOutage(new TypeError('Failed to fetch'))).toBe(false);
});
});
describe('looking up a send that may have gone', () => {
it('waits until about 30 seconds after the last try', async () => {
writeEnabled(['acc']);
const id = await queueSend('acc', { subject: 'Hello', to: [] }, { kind: 'email', args: [] }, undefined, 'fixed@example.org');
await claimForSending('acc', id);
await updateOutboxEntry({ account: 'acc', id }, { status: 'uncertain' });
const requestNetworkOnly = vi.fn(async () => { throw new TypeError('Failed to fetch'); });
const client = fakeClient('https://mail.example.org', { requestNetworkOnly, resolveSendAccountId: async () => 'j1', submissionAccountFor: () => 'j1' }) as unknown as JMAPClient;
const retryLater = vi.fn();
await flushQueue('acc', client, { notify: vi.fn(), allowed: () => true, retryLater });
expect(requestNetworkOnly).not.toHaveBeenCalled();
expect(retryLater).toHaveBeenCalled();
const later = Date.now() + LOOKUP_WAIT_MS + 1000;
const clock = vi.spyOn(Date, 'now').mockReturnValue(later);
try {
await flushQueue('acc', client, { notify: vi.fn(), allowed: () => true, retryLater });
expect(requestNetworkOnly).toHaveBeenCalled();
} finally {
clock.mockRestore();
}
});
});
describe('what the store is told to keep', () => {
it('follows what the account chose, on top of the defaults', () => {
expect(offlineScopeFor('acc')).toEqual(DEFAULT_SCOPE);
localStorage.setItem(OFFLINE_SCOPE_KEY, JSON.stringify({ acc: { attachments: false, days: 7 } }));
expect(offlineScopeFor('acc')).toEqual({ ...DEFAULT_SCOPE, attachments: false, days: 7 });
expect(offlineScopeFor('other')).toEqual(DEFAULT_SCOPE);
});
});
describe('a send on the local copy', () => {
it('queues, before the swap too, while the probes say the server is up', () => {
writeEnabled(['acc']);
stop = startOfflineMode();
useAuthStore.setState({ client: fakeClient('https://mail.example.org', { isOfflineOnly: () => true }) as never });
const client = useAuthStore.getState().client as never;
expect(isOfflineNow('https://mail.example.org')).toBe(false);
expect(getOutboxHooks()?.shouldQueue(client)).toBe(true);
useAuthStore.setState({ client: fakeClient() as never });
expect(getOutboxHooks()?.shouldQueue(useAuthStore.getState().client as never)).toBe(false);
});
});
describe('12: one server down doesn’t hold another’s mail', () => {
+51 -4
View File
@@ -11,7 +11,7 @@ import {
userChoiceFor,
windowOptions,
} from '../scope';
import { useOfflineStore } from '../state';
import { offlineScopeFor, useOfflineStore } from '../state';
const NO_LIMITS = DEFAULT_ORGANISATION_POLICY.devices;
const MB = 1024 * 1024;
@@ -25,7 +25,7 @@ describe('effectiveScope', () => {
});
it('the user over the default', () => {
const effective = effectiveScope('a', { on: true, days: 30, attachments: false, maxAttachmentMb: 5 }, NO_LIMITS, BROWSER);
const effective = effectiveScope('a', { on: true, days: 30, attachments: false, maxAttachmentBytes: 5 * MB }, NO_LIMITS, BROWSER);
expect(effective.enabled).toBe(true);
expect(effective.scope).toEqual({ ...DEFAULT_SCOPE, days: 30, attachments: false, maxAttachmentBytes: 5 * MB });
expect(effectiveScope('a', { on: false }, NO_LIMITS, DESKTOP).enabled).toBe(false);
@@ -41,11 +41,11 @@ describe('effectiveScope', () => {
it('a cap lowers the choice and the default, also "everything", and never raises', () => {
const policy = { ...NO_LIMITS, offlineMaxDays: 60, offlineMaxAttachmentMb: 10, offlineMaxTotalMb: 100 };
const capped = effectiveScope('a', { days: null, maxAttachmentMb: 500 }, policy, DESKTOP);
const capped = effectiveScope('a', { days: null, maxAttachmentBytes: 500 * MB }, policy, DESKTOP);
expect(capped.scope).toEqual({ days: 60, attachments: true, maxAttachmentBytes: 10 * MB, maxTotalBytes: 100 * MB });
expect(capped.locked).toMatchObject({ days: true, maxAttachment: true, maxTotal: true, attachments: false });
expect(effectiveScope('a', null, { ...policy, offlineMaxDays: 30 }, DESKTOP).scope.days).toBe(30);
const under = effectiveScope('a', { days: 30, maxAttachmentMb: 5 }, policy, BROWSER);
const under = effectiveScope('a', { days: 30, maxAttachmentBytes: 5 * MB }, policy, BROWSER);
expect(under.scope.days).toBe(30);
expect(under.scope.maxAttachmentBytes).toBe(5 * MB);
});
@@ -69,6 +69,53 @@ describe('the platform default', () => {
});
});
describe('the OfflineEnabled policy of the desktop app', () => {
const withPolicy = (OfflineEnabled: unknown) => {
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, policy: { get: () => ({ OfflineEnabled }) } };
};
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
it('recommended off changes the default, and a user can still turn it on', () => {
withPolicy({ value: false, locked: false, source: 'machine' });
expect(offlineDefaultOn()).toBe(false);
expect(effectiveScope('a', null, NO_LIMITS, platformDefault()).enabled).toBe(false);
expect(effectiveScope('a', { on: true }, NO_LIMITS, platformDefault()).enabled).toBe(true);
});
it('the shipped default is no recommendation', () => {
withPolicy({ value: false, locked: false, source: 'default' });
expect(offlineDefaultOn()).toBe(true);
});
it('forced beats what the user chose, either way', () => {
withPolicy({ value: true, locked: true, source: 'machine' });
expect(effectiveScope('a', { on: false }, NO_LIMITS, platformDefault())).toMatchObject({ enabled: true, locked: { forced: true } });
withPolicy({ value: false, locked: true, source: 'machine' });
expect(effectiveScope('a', { on: true }, NO_LIMITS, platformDefault()).enabled).toBe(false);
});
it('the organisation still beats it', () => {
withPolicy({ value: true, locked: true, source: 'machine' });
expect(effectiveScope('a', null, { ...NO_LIMITS, noOffline: true }, platformDefault()).enabled).toBe(false);
});
it('a shell without the member, or a member that throws, is the plain default', () => {
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 };
expect(offlineDefaultOn()).toBe(true);
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, policy: { get: () => { throw new Error('gone'); } } };
expect(platformDefault()).toMatchObject({ on: true, forced: null });
});
});
describe('the seam for what a user chose', () => {
it('reads bulwark-offline-scope over the defaults, without the switch', () => {
localStorage.setItem('bulwark-offline-scope', JSON.stringify({ acc: { on: true, days: 30 } }));
expect(offlineScopeFor('acc')).toEqual({ ...DEFAULT_SCOPE, days: 30 });
expect(offlineScopeFor('other')).toEqual(DEFAULT_SCOPE);
localStorage.removeItem('bulwark-offline-scope');
});
});
describe('what the user chose', () => {
beforeEach(() => useOfflineStore.setState({ enabled: [], choices: {} }));
+49 -43
View File
@@ -15,8 +15,8 @@ import {
wipeEverything,
type OfflineNotice,
} from '../offline-mode';
import { readPersistence, requestPersistence, safariMayDelete, wasEvicted } from '../storage-health';
import { OFFLINE_CHOICES_KEY, OFFLINE_HEALTH_KEY, useOfflineStore, writeChoice, writeEnabled, writeHealth } from '../state';
import { readPersistence, requestPersistence, safariMayDelete } from '../storage-health';
import { markEvicted, OFFLINE_HEALTH_KEY, OFFLINE_SCOPE_KEY, useOfflineStore, writeChoice, writeEnabled, writeHealth } from '../state';
/** Everything on one page: a BroadcastChannel that also reaches other objects here, as in a browser. */
class FakeChannel {
@@ -37,8 +37,8 @@ class FakeChannel {
class FakeWorker {
static all: FakeWorker[] = [];
/** What the worker found on OPFS. */
static fileExisted = true;
/** What the worker found on OPFS: no database under this name. */
static created = false;
onmessage: ((event: MessageEvent) => void) | null = null;
posted: Array<Record<string, unknown>> = [];
constructor() {
@@ -46,8 +46,8 @@ class FakeWorker {
}
postMessage(message: Record<string, unknown>): void {
this.posted.push(message);
if (message.kind === 'open') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: { fileExisted: FakeWorker.fileExisted } } } as MessageEvent));
if (message.kind === 'sweep-files') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: 0 } } as MessageEvent));
if (message.kind === 'open') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: { created: FakeWorker.created } } } as MessageEvent));
if (message.kind === 'prune-files') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id } } as MessageEvent));
}
terminate(): void {}
}
@@ -90,17 +90,17 @@ beforeEach(() => {
deleteKeyDatabase();
FakeChannel.open.clear();
FakeWorker.all = [];
FakeWorker.fileExisted = true;
FakeWorker.created = false;
rpcs.length = 0;
notices.length = 0;
vi.stubGlobal('BroadcastChannel', FakeChannel);
vi.stubGlobal('Worker', FakeWorker);
vi.stubGlobal('FileSystemFileHandle', class {});
setStorage({ getDirectory: async () => ({ removeEntry: async () => {} }), persist: async () => true, persisted: async () => true });
Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { request: async (_name: string, fn: () => unknown) => fn() } });
Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { request: async (_name: string, ...rest: unknown[]) => (rest.at(-1) as () => unknown)() } });
useOfflineStore.setState({
enabled: [], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usage: {}, usageBytes: 0, choices: {}, health: {},
storageFull: {}, progress: {}, persist: 'unknown', syncing: [], lastSyncedAt: {}, browserOffline: false, downServers: [], startedOffline: false,
storeLost: [], storageFull: {}, progress: {}, persist: 'unknown', syncing: [], lastSyncedAt: {}, browserOffline: false, downServers: [], startedOffline: false,
});
usePolicyStore.setState({ loaded: true, source: 'server', subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never);
setStoreBackend(null);
@@ -131,57 +131,63 @@ async function openAsOwner(account: string): Promise<FakeWorker> {
}
describe('a store the browser cleared', () => {
const hadStore = (account: string) => localStorage.setItem('bulwark-store-opened', JSON.stringify([account]));
it('a key with no file: warns, with how many waiting items went', async () => {
await openAccountKey('acc');
writeHealth('acc', { opened: true, pending: 3 });
FakeWorker.fileExisted = false;
hadStore('acc');
writeHealth('acc', { pending: 3 });
FakeWorker.created = true;
await openAsOwner('acc');
await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'evicted', lost: 3 }));
expect(useOfflineStore.getState().health.acc.evicted?.lost).toBe(3);
expect(useOfflineStore.getState().health.acc.pending).toBe(0);
await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'store-reset', lost: 3 }));
const state = useOfflineStore.getState();
expect(state.storeLost).toEqual(['acc']);
expect(state.health.acc.reset?.lost).toBe(3);
expect(state.health.acc.pending).toBe(0);
});
it('no key where one was made: warns too', async () => {
writeHealth('acc', { opened: true, pending: 0 });
FakeWorker.fileExisted = false;
hadStore('acc');
await openAsOwner('acc');
await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'evicted', lost: 0 }));
await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'store-reset', lost: 0 }));
});
it('a first open is not an eviction', async () => {
FakeWorker.fileExisted = false;
await openAsOwner('acc');
await vi.waitFor(() => expect(useOfflineStore.getState().health.acc?.opened).toBe(true));
expect(notices.filter((n) => n.kind === 'evicted')).toEqual([]);
FakeWorker.created = true;
const worker = await openAsOwner('acc');
await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'open')).toBe(true));
await new Promise((resolve) => setTimeout(resolve, 5));
expect(notices.filter((n) => n.kind === 'store-reset')).toEqual([]);
expect(useOfflineStore.getState().health.acc?.reset).toBeUndefined();
});
it('a normal start is not one either', async () => {
await openAccountKey('acc');
writeHealth('acc', { opened: true, pending: 3 });
hadStore('acc');
writeHealth('acc', { pending: 3 });
await openAsOwner('acc');
await vi.waitFor(() => expect(FakeWorker.all[0].posted.some((m) => m.kind === 'open')).toBe(true));
await new Promise((resolve) => setTimeout(resolve, 5));
expect(notices).toEqual([]);
expect(useOfflineStore.getState().health.acc.pending).toBe(3);
});
it('files with no key are swept once this tab owns the store', async () => {
const { record } = await openAccountKey('acc');
const worker = await openAsOwner('acc');
await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'sweep-files')).toBe(true));
expect((worker.posted.find((m) => m.kind === 'sweep-files') as { known: string[] }).known).toEqual([record.file]);
});
it('wasEvicted', () => {
expect(wasEvicted({ opened: true, hadKey: true, fileExisted: false })).toBe(true);
expect(wasEvicted({ opened: false, hadKey: true, fileExisted: false })).toBe(true);
expect(wasEvicted({ opened: true, hadKey: false, fileExisted: false })).toBe(true);
expect(wasEvicted({ opened: false, hadKey: false, fileExisted: false })).toBe(false);
expect(wasEvicted({ opened: true, hadKey: true, fileExisted: true })).toBe(false);
it('tells nobody who wasn\'t signed in to an account dropped by the app', async () => {
await openAccountKey('acc');
hadStore('acc');
writeHealth('acc', { pending: 3 });
useOfflineStore.setState({ enabled: ['acc'] });
markEvicted('acc');
useOfflineStore.setState({ enabled: [] });
FakeWorker.created = true;
await openAsOwner('acc');
await new Promise((resolve) => setTimeout(resolve, 5));
expect(notices).toEqual([]);
expect(useOfflineStore.getState().health.acc.reset).toBeUndefined();
});
it('keeps only numbers about it outside the store', () => {
writeHealth('acc', { opened: true, pending: 2, evicted: { at: 5, lost: 2 }, lastSync: 9 });
expect(JSON.parse(localStorage.getItem(OFFLINE_HEALTH_KEY)!)).toEqual({ acc: { opened: true, pending: 2, evicted: { at: 5, lost: 2 }, lastSync: 9 } });
writeHealth('acc', { pending: 2, reset: { at: 5, lost: 2 }, lastSync: 9 });
expect(JSON.parse(localStorage.getItem(OFFLINE_HEALTH_KEY)!)).toEqual({ acc: { pending: 2, reset: { at: 5, lost: 2 }, lastSync: 9 } });
});
});
@@ -264,7 +270,7 @@ describe('removing an account', () => {
writeEnabled(['gone', 'stays']);
writeChoice('gone', { days: 30 });
writeChoice('stays', { days: 90 });
writeHealth('gone', { opened: true, pending: 1 });
writeHealth('gone', { pending: 1 });
await openAsOwner('stays');
await wipeAccount('gone');
@@ -274,7 +280,7 @@ describe('removing an account', () => {
expect(rpcs).toContainEqual({ method: 'removeAccount', params: { account: 'gone', file: record.file } });
expect(useOfflineStore.getState().choices).toEqual({ stays: { days: 90 } });
expect(useOfflineStore.getState().health.gone).toBeUndefined();
expect(JSON.parse(localStorage.getItem(OFFLINE_CHOICES_KEY)!)).toEqual({ stays: { days: 90 } });
expect(JSON.parse(localStorage.getItem(OFFLINE_SCOPE_KEY)!)).toEqual({ stays: { days: 90 } });
});
it('with the last account, deletes the pool and every key', async () => {
@@ -288,7 +294,7 @@ describe('removing an account', () => {
expect(removeEntry).toHaveBeenCalledWith('.bulwark-store', { recursive: true });
await vi.waitFor(async () => expect(await getAccountRecord('acc')).toBeNull());
expect(localStorage.getItem(OFFLINE_CHOICES_KEY)).toBeNull();
expect(localStorage.getItem(OFFLINE_SCOPE_KEY)).toBeNull();
expect(localStorage.getItem(OFFLINE_HEALTH_KEY)).toBeNull();
});
@@ -313,13 +319,13 @@ describe('a full sign-out', () => {
await openAccountKey('acc');
writeEnabled(['acc']);
writeChoice('acc', { days: 30 });
writeHealth('acc', { opened: true, pending: 2 });
writeHealth('acc', { pending: 2 });
purgeSignedOutData();
await vi.waitFor(() => expect(removeEntry).toHaveBeenCalledWith('.bulwark-store', { recursive: true }));
await vi.waitFor(async () => expect(await getAccountRecord('acc')).toBeNull());
for (const key of ['bulwark-offline-accounts', OFFLINE_CHOICES_KEY, OFFLINE_HEALTH_KEY]) expect(localStorage.getItem(key)).toBeNull();
for (const key of ['bulwark-offline-accounts', OFFLINE_SCOPE_KEY, OFFLINE_HEALTH_KEY]) expect(localStorage.getItem(key)).toBeNull();
expect(useOfflineStore.getState().choices).toEqual({});
expect(useOfflineStore.getState().health).toEqual({});
});
+5 -4
View File
@@ -1,8 +1,9 @@
/**
* The server refused this account's refresh token for good. Nothing wipes on
* this yet: a refused account is dropped, and its local data stays for the next
* sign-in. HOOK: the wipe work (lib/store/wipe) registers here to act on a
* permanent rejection. Free of the stores, so the auth store can call it.
* The server really refused this login (`invalid_grant`), which the auth store
* follows with a sign-out that wipes the account's local data. An outage or any
* other refusal doesn't come here, and keeps the data. HOOK: lib/store/wipe
* registers here for what else a permanent rejection should do. Free of the
* stores, so the auth store can call it.
*/
type Listener = (accountId: string) => void;
+63 -15
View File
@@ -11,9 +11,10 @@
* - A message is sent as it was finished at Send (queue.ts); files attached
* offline are uploaded first. A hold for undo-send that has passed is
* dropped, so the message goes at once.
* - Only a failure before the submission request (SendNotStartedError, a
* file upload on a dead network) puts a message back to waiting. A send
* whose connection broke after that is marked uncertain. A refusal by the
* - A failure before the submission request (SendNotStartedError, a file
* upload on a dead network), a rate limit, a login that needs renewing and
* trouble with this device's store put a message back to waiting. A send whose
* connection broke, or that got a 5xx, is marked uncertain. A refusal by the
* server is marked failed, with the reason.
* - An uncertain message is looked up when the server is back, by the
* Message-ID fixed when it was queued (lib/store/outbox/dedupe.ts): it's
@@ -29,6 +30,7 @@ import type { JMAPClient } from '@/lib/jmap/client';
import { isNetworkFailure } from '@/lib/jmap/offline-hooks';
import type { SendEmailResult } from '@/lib/jmap/types';
import { getStore } from '@/lib/store/client/api';
import { isStorageFailure } from '@/lib/store/storage';
import type { MethodCall } from '@/lib/store/engine/transport';
import { decideRetry, type LookupIo, type RetryDecision } from '@/lib/store/outbox/dedupe';
import { flushPatches, type PatchIo, type PatchOutcome, type PendingPatch } from '@/lib/store/outbox/flush-patches';
@@ -42,7 +44,7 @@ import {
type OutboxEntry,
type OutboxError,
} from './queue';
import { useOfflineStore } from './state';
import { visibleAccounts } from './state';
import type { OfflineNotice } from './offline-mode';
export interface FlushDeps {
@@ -56,6 +58,9 @@ export interface FlushDeps {
retryLater?: () => void;
}
/** A message that just went may not show in a lookup yet, which would send it twice. */
export const LOOKUP_WAIT_MS = 30_000;
/** A send is taken for at most this long before a closed tab's send counts as uncertain. */
export const SENDING_STALE_MS = 60_000;
@@ -72,6 +77,23 @@ class OutboxPrepError extends Error {
}
}
/**
* Not the server's word about this message: it's rate limiting, the login needs
* renewing, or this device's store is in the way. The next try goes as usual.
*/
function waitsForNextTry(error: unknown): boolean {
if (!(error instanceof Error)) return false;
if (error.name === 'RateLimitError' || error.name === 'StoreOwnerGoneError' || error.name === 'StoreKeyError') return true;
if (isStorageFailure(error)) return true;
return /^Request failed: (401|408|429)\b/.test(error.message);
}
/** A server error can come after the message went through: looked up before it goes again. */
function mayHaveArrived(error: unknown): boolean {
if (isNetworkFailure(error)) return true;
return error instanceof Error && (error.name === 'SendUnconfirmedError' || /^Request failed: 5\d\d\b/.test(error.message));
}
function notStartedError(message: string): Error {
return Object.assign(new Error(message), { name: 'SendNotStartedError' });
}
@@ -89,12 +111,16 @@ function notStarted(error: unknown): boolean {
async function uploadHeld(account: string, client: JMAPClient, blobId: string): Promise<string> {
const file = await heldFile(account, blobId);
if (!file) throw new OutboxPrepError('file_missing');
let uploaded: string;
try {
return (await client.uploadBlob(file)).blobId;
uploaded = (await client.uploadBlob(file)).blobId;
} catch (error) {
if (isNetworkFailure(error)) throw notStartedError('upload failed');
throw error;
}
// The server went away just now, and offline mode held the file again.
if (isLocalBlobId(uploaded)) throw notStartedError('upload held');
return uploaded;
}
async function sendEntry(account: string, client: JMAPClient, entry: OutboxEntry): Promise<SendEmailResult> {
@@ -150,15 +176,28 @@ export async function flushQueue(account: string, client: JMAPClient, deps: Flus
await run();
}
/** Marks sends of closed tabs uncertain (they may have gone out). */
export async function settleStaleSends(account?: string, now = Date.now()): Promise<void> {
const accounts = account ? [account] : useOfflineStore.getState().enabled;
await Promise.all(accounts.map((id) => getStore().area(id, 'outbox.settleStale', { before: now - SENDING_STALE_MS })));
/** A file attached offline to something that was then discarded goes after this long. */
export const ORPHAN_FILE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const ORPHAN_CHECK_MS = 24 * 60 * 60 * 1000;
const orphansCheckedAt = new Map<string, number>();
async function dropOrphanFiles(account: string): Promise<void> {
const now = Date.now();
if (now - (orphansCheckedAt.get(account) ?? 0) < ORPHAN_CHECK_MS) return;
orphansCheckedAt.set(account, now);
await getStore().area(account, 'outbox.dropOrphans', { olderThan: now - ORPHAN_FILE_AGE_MS }).catch(() => {});
}
/** The server refused the request itself: more tries won't change that. Outages and auth wait instead. */
function isRequestRefusal(error: unknown): boolean {
return error instanceof Error && /^Request failed: (400|403|404|413|422)\b/.test(error.message);
/** Tests only. */
export function forgetOrphanCheck(): void {
orphansCheckedAt.clear();
}
/** Marks sends of closed tabs uncertain (they may have gone out). */
export async function settleStaleSends(account?: string, now = Date.now()): Promise<void> {
const accounts = account ? [account] : visibleAccounts();
// One account's store in trouble doesn't hold up another's.
await Promise.allSettled(accounts.map((id) => getStore().area(id, 'outbox.settleStale', { before: now - SENDING_STALE_MS })));
}
/** The changes queued before `upToSeq` (all, with null). False when the run stopped on an outage. */
@@ -167,7 +206,6 @@ async function sendPatches(account: string, client: JMAPClient, deps: FlushDeps,
if (pending.length === 0) return true;
const io: PatchIo = {
request: (calls: MethodCall[], using: string[]) => client.requestNetworkOnly(calls, using),
isRefusal: isRequestRefusal,
};
return flushPatches(io, pending, async (outcomes: PatchOutcome[]) => {
const conflicts = await getStore().area<number>(account, 'outbox.patchResults', { outcomes });
@@ -207,6 +245,10 @@ async function settleUncertain(account: string, client: JMAPClient, deps: FlushD
for (const entry of outbox) {
if (entry.status !== 'uncertain') continue;
if (stillOffline() || !deps.allowed()) return false;
if (entry.lastAttemptAt && Date.now() - entry.lastAttemptAt < LOOKUP_WAIT_MS) {
deps.retryLater?.();
continue;
}
let decision: RetryDecision;
try {
decision = await lookUp(client, entry);
@@ -271,8 +313,13 @@ async function flushLocked(account: string, client: JMAPClient, deps: FlushDeps)
deps.notify({ kind: 'send-failed', subject: entry.summary.subject, error: { code } });
continue;
}
if (isNetworkFailure(error) || (error instanceof Error && error.name === 'SendUnconfirmedError')) {
// It may have reached the server: looked up before it goes again.
if (waitsForNextTry(error)) {
// Nothing the user can do about it, and the copy on the server stays useful.
await updateOutboxEntry(entry, { status: 'waiting' });
deps.retryLater?.();
return;
}
if (mayHaveArrived(error)) {
await updateOutboxEntry(entry, { status: 'uncertain' });
deps.retryLater?.();
return;
@@ -290,6 +337,7 @@ async function flushLocked(account: string, client: JMAPClient, deps: FlushDeps)
}
if (stillOffline() || !deps.allowed()) return;
await sendPatches(account, client, deps, null);
await dropOrphanFiles(account);
} finally {
flushing.delete(account);
}
+16 -1
View File
@@ -145,7 +145,8 @@ async function readAccount(db: IDBDatabase, key: CryptoKey | null, account: stri
if (item.kind === 'raw') messageId = rawIds.get(item.blobId);
// Not sent yet: it can still get a fixed id. One that left under the old
// code had another id, which nothing here knows.
else if (status === 'waiting' && typeof item.args[6] === 'string') messageId = generateMessageId(item.args[6]);
// Only its domain is used, and the sender may be written "Name <address>".
else if (status === 'waiting' && typeof item.args[6] === 'string') messageId = generateMessageId(item.args[6].replace(/>.*$/, ''));
part.entries.push({
summary: payload.summary,
item,
@@ -193,6 +194,20 @@ export function moveLegacyOutbox(): Promise<void> {
return moving;
}
/** Messages an earlier version left in its database. For a browser the store can't run in, which can't move them. */
export async function strandedLegacyMessages(): Promise<number> {
if (typeof indexedDB === 'undefined') return 0;
const db = await openLegacy().catch(() => null);
if (!db) return 0;
try {
if (!storeNamesHold(db, 'queue')) return 0;
const records = await result(db.transaction('queue', 'readonly').objectStore('queue').getAll()) as Array<{ kind?: string }>;
return records.filter((record) => record.kind !== 'patch').length;
} finally {
db.close();
}
}
/** Tests only: forget that this page already ran it. */
export function forgetLegacyMove(): void {
moving = null;
+203 -58
View File
@@ -11,16 +11,19 @@
* - Changes and sends made offline wait in the store's outbox and go out in
* order once the server answers again and the organisation's policy was
* fetched afresh (flush.ts).
* - Everything is wiped at a full sign-out and the automatic sign-outs
* (lib/sign-out-cleanup.ts deletes the store and its keys), for one account
* when it signs out, is removed or switches offline mode off, and when the
* organisation forbids it (checked on every policy fetch). An account the
* app drops by itself (its session cookie ran out, say) keeps its data for
* the next sign-in. Unsent messages a sign-out deletes without the user's
* word are named (lost.ts).
* - Everything is wiped at a full sign-out, at the session limits and when the
* server really refuses the login (`invalid_grant`), by
* lib/sign-out-cleanup.ts; for one account when it signs out, is removed or
* switches offline mode off, and when the organisation forbids it (checked on
* every policy fetch). An account the app drops by itself because it couldn't
* confirm the login (an outage, an unknown refusal, a session cookie that ran
* out) keeps its data for 30 days or the next sign-in, hidden from everyone
* else. Unsent messages a sign-out deletes without the user's word are named
* (lost.ts).
*/
import { IS_LITE } from '@/lib/lite';
import { desktopJmapBase } from '@/lib/desktop/jmap-route';
import { devicesForAccount, signedInAccountIds } from '@/lib/device-policy';
import { accountSubjectPending, useAccountSubjects } from '@/lib/org/account-subjects';
import { labelsPolicyFor } from '@/lib/outgoing/classification';
@@ -37,7 +40,7 @@ import { useEmailStore } from '@/stores/email-store';
import { usePolicyStore, whenPolicySettled } from '@/stores/policy-store';
import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup';
import { generateMessageId } from '@/lib/jmap/client';
import { setOfflineHooks, type OfflineClientRef, type OfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks';
import { SendNotStartedError, setOfflineHooks, type OfflineClientRef, type OfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks';
import type { SendEmailResult } from '@/lib/jmap/types';
import { setOutboxHooks, type OutboxHooks } from './outbox-hooks';
import {
@@ -53,18 +56,24 @@ import {
} from './queue';
import { clearHandoffs } from '@/lib/launch-handoff';
import {
clearEvicted,
EVICTED_KEEP_MS,
forgetAccountState,
forgetAllAccountState,
forgetMailSnapshot,
isEvicted,
isOfflineNow,
OFFLINE_ACCOUNTS_KEY,
OFFLINE_CHOICES_KEY,
OFFLINE_HEALTH_KEY,
OFFLINE_SCOPE_KEY,
readEnabledFrom,
readEvicted,
readStoredChoices,
readStoredHealth,
serverKey,
setServerDown,
useOfflineStore,
visibleAccounts,
writeChoice,
writeEnabled,
writeHealth,
@@ -74,10 +83,10 @@ import {
} from './state';
import { effectiveScope, offlineDefaultOn, olderThanCap, platformDefault, userChoiceFor, type EffectiveScope } from './scope';
import { isStandaloneDisplayMode } from '@/lib/standalone-display';
import { readPersistence, requestPersistence, safariMayDelete, wasEvicted } from './storage-health';
import { readPersistence, requestPersistence, safariMayDelete } from './storage-health';
import { nextProgress } from './sync-progress';
import { flushQueue, settleStaleSends } from './flush';
import { LEGACY_DB_NAME, moveLegacyOutbox } from './migrate-legacy';
import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from './flush';
import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy';
import { deleteShellCaches, tellServiceWorker } from './shell';
import { rememberLost } from './lost';
@@ -89,7 +98,9 @@ export type OfflineNotice =
| { kind: 'lost'; subjects: string[] }
| { kind: 'conflict'; count: number }
| { kind: 'storage-failed' }
| { kind: 'evicted'; lost: number }
| { kind: 'store-unavailable' }
| { kind: 'store-reset'; lost: number }
| { kind: 'legacy-stranded' }
| { kind: 'default-on' }
| { kind: 'refused-delete' }
| { kind: 'refused-other' }
@@ -120,6 +131,7 @@ function hasIndexedDb(): boolean {
/** Offline mode is possible here at all: not in Lite, with what the store needs. */
export function offlineSupported(): boolean {
if (IS_LITE || !hasIndexedDb()) return false;
if (useOfflineStore.getState().storageBlocked) return false;
try {
return typeof crypto !== 'undefined' && !!crypto.subtle && getStore().supported();
} catch {
@@ -168,6 +180,16 @@ function onlineClientFor(account: string) {
/** A full disk stays full: once in a while is enough to say so. */
const STORAGE_NOTICE_MS = 30 * 60_000;
let lastStorageNotice = 0;
let lastUnavailableNotice = 0;
/** The store won't open: what waits in it is out of reach, and the user should know why. */
function noteStoreUnavailable(): void {
if (useOfflineStore.getState().enabled.length === 0) return;
const now = Date.now();
if (now - lastUnavailableNotice < STORAGE_NOTICE_MS) return;
lastUnavailableNotice = now;
notify({ kind: 'store-unavailable' });
}
const storeHooks: StorePageHooks = {
inputs(account) {
@@ -188,16 +210,14 @@ const storeHooks: StorePageHooks = {
await client.requestNetworkOnly([['Core/echo', {}, '0']]);
return client.getAuthHeader();
},
onOpened(account, found) {
const health = useOfflineStore.getState().health[account];
if (wasEvicted({ opened: !!health?.opened, ...found })) {
const lost = health?.pending ?? 0;
writeHealth(account, { opened: true, pending: 0, evicted: { at: Date.now(), lost } });
notify({ kind: 'evicted', lost });
void refreshQueueState();
return;
}
writeHealth(account, { opened: true });
onStoreLost(account) {
// Whoever is here wasn't signed in to a dropped account: nothing to tell them.
if (isEvicted(account)) return;
const lost = useOfflineStore.getState().health[account]?.pending ?? 0;
useOfflineStore.setState((s) => ({ storeLost: [...new Set([...s.storeLost, account])] }));
writeHealth(account, { pending: 0, reset: { at: Date.now(), lost } });
notify({ kind: 'store-reset', lost });
void refreshQueueState();
},
onEvent(event) {
if (event.type === 'synced') {
@@ -207,6 +227,7 @@ const storeHooks: StorePageHooks = {
return { lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at }, storageFull, progress };
});
writeHealth(event.account, { lastSync: event.at });
forgetMailSnapshot(event.account);
void refreshUsage();
// A pass also sends what the calendar and contacts have waiting.
void refreshQueueState();
@@ -269,7 +290,7 @@ export function probeServer(serverUrl: string): Promise<boolean> {
if (running) return running;
const probe = (async () => {
try {
await fetch(`${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit', signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
await fetch(`${desktopJmapBase(serverUrl.replace(/\/+$/, ''))}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit', signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
if (setServerDown(serverUrl, false)) scheduleFlushAll(500);
return false;
} catch {
@@ -452,6 +473,8 @@ const outbox: OutboxHooks = {
shouldQueue(client) {
const account = accountForClient(client);
if (!account || !offlineActive(account)) return false;
// A local copy has no way to the server, whatever the probes last said.
if (client.isOfflineOnly?.()) return true;
return isOfflineNow(client.getServerUrl()) || (typeof navigator !== 'undefined' && navigator.onLine === false);
},
async queueEmail(client, args) {
@@ -494,6 +517,8 @@ const outbox: OutboxHooks = {
const file = await heldFile(account, attachment.blobId);
if (!file) throw Object.assign(new Error('A file attached offline is no longer on this device'), { name: 'OfflineFileMissingError' });
const { blobId } = await real.uploadBlob(file);
// The server went away just now, and offline mode held the file again.
if (isLocalBlobId(blobId)) throw new SendNotStartedError();
out.push({ ...attachment, blobId });
}
return out;
@@ -532,14 +557,15 @@ export async function refreshQueueState(): Promise<void> {
if (!offlineSupported()) return;
// Nothing is kept for an account that hasn't it on; and opening the
// store would create it (on the sign-in page, after a sign-out).
if (useOfflineStore.getState().enabled.length === 0) {
if (visibleAccounts().length === 0) {
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [] });
return;
}
try {
// Sends of a tab closed on the way: uncertain, so they can be looked up.
await settleStaleSends();
const { patches, patchesBy, outbox: entries, conflicts } = await readQueue();
const { failed, patches, patchesBy, outbox: entries, conflicts } = await readQueue();
if (failed.length > 0) noteStoreUnavailable();
const rows: OutboxRow[] = entries.map((entry) => ({
id: entry.id,
account: entry.account,
@@ -549,17 +575,23 @@ export async function refreshQueueState(): Promise<void> {
status: entry.status,
error: entry.error,
}));
const { enabled } = useOfflineStore.getState();
const areas = await Promise.all(enabled.map(readAreaState));
useOfflineStore.setState({ outbox: rows, pendingChanges: patches, conflicts, areaConflicts: areas.flatMap((area) => area.conflicts) });
// What an account's store can't tell right now is still there.
const unread = useOfflineStore.getState().outbox.filter((row) => failed.includes(row.account));
const accounts = visibleAccounts();
const areas = await Promise.all(accounts.map(readAreaState));
useOfflineStore.setState({ outbox: [...unread, ...rows], pendingChanges: patches, conflicts, areaConflicts: areas.flatMap((area) => area.conflicts) });
if (rows.some((row) => row.status === 'sending')) checkStaleSendsLater();
// What a browser clearing the store would lose. Counted here, and the
// count is all that's kept outside the store.
enabled.forEach((account, index) => {
// count is all that's kept outside the store. A store that couldn't be
// read keeps its last count.
accounts.forEach((account, index) => {
if (failed.includes(account)) return;
const sends = rows.filter((row) => row.account === account).length;
writeHealth(account, { pending: sends + (patchesBy[account] ?? 0) + areas[index].pending });
});
} catch {
// The store is being deleted, or can't start.
noteStoreUnavailable();
}
}
@@ -607,13 +639,13 @@ export async function dismissAreaConflict(conflict: AreaConflict): Promise<void>
}
export async function refreshUsage(): Promise<void> {
const { enabled } = useOfflineStore.getState();
if (!offlineSupported() || enabled.length === 0) return;
const accounts = visibleAccounts();
if (!offlineSupported() || accounts.length === 0) return;
try {
const usage = await Promise.all(enabled.map((account) => getStore().area<StoreUsage>(account, 'store.usage')));
const usage = await Promise.all(accounts.map((account) => getStore().area<StoreUsage>(account, 'store.usage')));
useOfflineStore.setState({
usageBytes: usage.reduce((sum, { bytes }) => sum + bytes, 0),
usage: Object.fromEntries(enabled.map((account, index) => [account, usage[index]])),
usage: Object.fromEntries(accounts.map((account, index) => [account, usage[index]])),
});
} catch {
/* deleted meanwhile */
@@ -668,7 +700,7 @@ export function scheduleFlushAll(delayMs = 1_000): void {
/** How long after a failed policy fetch the outbox asks again. */
const POLICY_RETRY_MS = 60_000;
/** A lost response with the server still there: nothing else would start the lookup. */
const LOOKUP_AGAIN_MS = 30_000;
const LOOKUP_AGAIN_MS = LOOKUP_WAIT_MS + 1_000;
/** A send held by the server for undo, from the outbox: the undo toast as after any send. */
function offerUndo(entry: OutboxEntry, result: SendEmailResult): void {
@@ -795,7 +827,8 @@ export async function clearAccount(account: string): Promise<void> {
} catch {
// No store: nothing to remove. The key is gone in any case.
}
writeHealth(account, { opened: false, pending: 0, evicted: undefined, lastSync: undefined });
useOfflineStore.setState((s) => ({ storeLost: s.storeLost.filter((id) => id !== account) }));
writeHealth(account, { pending: 0, reset: undefined, lastSync: undefined, clearedAt: Date.now() });
useOfflineStore.setState((s) => {
const { [account]: _usage, ...usage } = s.usage;
const { [account]: _full, ...storageFull } = s.storageFull;
@@ -818,7 +851,7 @@ export function chooseScope(account: string, patch: UserChoice): void {
/** The store's scope follows the page's answer. Nothing happens in the store if it's the same as before. */
function pushScope(account: string): void {
if (!useOfflineStore.getState().enabled.includes(account) || !scopeDecided(account) || !offlineActive(account)) return;
if (!visibleAccounts().includes(account) || !scopeDecided(account) || !offlineActive(account)) return;
const scope: StoreScope = scopeForAccount(account).scope;
void getStore().setScope(account, scope).then(() => refreshUsage()).catch(() => {});
}
@@ -846,9 +879,10 @@ function applyDefaultOn(): void {
}
}
/** The evicted warning was read. */
export function dismissEvicted(account: string): void {
writeHealth(account, { evicted: undefined });
/** The warning that the store came back empty was read. */
export function dismissStoreReset(account: string): void {
useOfflineStore.setState((s) => ({ storeLost: s.storeLost.filter((id) => id !== account) }));
writeHealth(account, { reset: undefined });
}
export { readPersistence, requestPersistence, safariMayDelete };
@@ -879,6 +913,28 @@ async function unsentSubjects(scope: string): Promise<string[]> {
return rows.filter((row) => scope === 'all' || row.account === scope).map((row) => row.subject);
}
/** How long a sign-out waits for the store's word on what would be lost. */
const UNSENT_READ_MS = 2_000;
/** What a sign-out of `scope` would delete: messages, and edits the server hasn't got. */
export async function unsentCount(scope: string, waitMs = UNSENT_READ_MS): Promise<{ messages: number; changes: number }> {
const shown = useOfflineStore.getState();
const known = { messages: shown.outbox.filter((row) => scope === 'all' || row.account === scope).length, changes: shown.pendingChanges };
const accounts = (scope === 'all' ? visibleAccounts() : [scope]).filter((account) => shown.enabled.includes(account));
if (accounts.length === 0) return known;
const pendingIn = (account: string, area: string) => getStore().area<number>(account, `${area}.pending`).catch(() => 0);
const read = (async () => {
const { failed, outbox, patches } = await readQueue(scope === 'all' ? undefined : scope);
const edits = await Promise.all(accounts.flatMap((account) => [pendingIn(account, 'calendar'), pendingIn(account, 'contacts')]));
return {
// An account that can't answer counts with what it last said.
messages: outbox.length + shown.outbox.filter((row) => failed.includes(row.account)).length,
changes: patches + edits.reduce((sum, count) => sum + count, 0),
};
})().catch(() => known);
return Promise.race([read, new Promise<typeof known>((resolve) => setTimeout(() => resolve(known), waitMs))]);
}
/** Names the unsent messages a wipe of `scope` deletes, unless the user agreed. */
async function nameLost(scope: string, stillSignedIn: boolean): Promise<void> {
if (named.has(scope) || named.has('all')) return;
@@ -900,10 +956,13 @@ export async function wipeAccount(account: string): Promise<void> {
const timer = syncTimers.get(account);
if (timer) clearTimeout(timer);
syncTimers.delete(account);
// Waited for a sign-in that never came: whoever is here now mustn't read its subjects.
if (isEvicted(account)) named.add(account);
if (useOfflineStore.getState().enabled.includes(account) || useOfflineStore.getState().outbox.some((row) => row.account === account)) {
await nameLost(account, useAuthStore.getState().isAuthenticated);
}
named.delete(account);
clearEvicted(account);
if (useOfflineStore.getState().enabled.includes(account)) {
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
}
@@ -952,6 +1011,8 @@ function scopeDecided(account: string): boolean {
/** Whether the policy of an account is known well enough to decide on it. */
function policyDecided(account: string): boolean {
// Dropped, with no groups to wait for: what applies to everyone reaches it.
if (isEvicted(account)) return true;
const auth = useAuthStore.getState();
if (account === auth.activeAccountId || !auth.activeAccountId) return !usePolicyStore.getState().subjectPending;
// Another signed-in account: its groups have to be known first (a group
@@ -966,10 +1027,14 @@ export function enforcePolicy(): void {
if (!loaded) return;
for (const account of useOfflineStore.getState().enabled) {
if (!policyDecided(account)) continue;
if (!offlineAllowed(account)) {
// What the organisation says, not whether this window can open the store:
// a blocked store says nothing against the data in it.
if (devicesForAccount(account).noOffline) {
// Whoever is here now wasn't signed in to a dropped account.
const someoneElses = isEvicted(account);
clearHandoffs();
void wipeAccount(account);
notify({ kind: 'forbidden' });
if (!someoneElses) notify({ kind: 'forbidden' });
continue;
}
// A lowered cap prunes what's kept, and a raised one starts fetching.
@@ -1033,6 +1098,7 @@ export function registerOfflineHooks(): void {
/** Runs offline mode in this tab. Safe to call more than once. */
export function startOfflineMode(): () => void {
if (!started && !offlineSupported()) void noteStrandedMail();
if (started || !offlineSupported()) return () => {};
started = true;
registerOfflineHooks();
@@ -1065,14 +1131,14 @@ export function startOfflineMode(): () => void {
// there): stop here too, and don't write anything for that account. One
// switched on there has to be opened here if this tab owns the store.
const onStorage = (event: StorageEvent) => {
if (event.key === OFFLINE_CHOICES_KEY || event.key === null) useOfflineStore.setState({ choices: readStoredChoices() });
if (event.key === OFFLINE_SCOPE_KEY || event.key === null) useOfflineStore.setState({ choices: readStoredChoices() });
if (event.key === OFFLINE_HEALTH_KEY || event.key === null) {
const before = useOfflineStore.getState().health;
const health = readStoredHealth();
useOfflineStore.setState({ health });
// Another tab cleared an account that stays on: if this tab owns the store, it opens the new one.
for (const id of useOfflineStore.getState().enabled) {
if (before[id]?.opened && health[id]?.opened === false) void getStore().ensureOpen(id).catch(() => {});
if (health[id]?.clearedAt && health[id].clearedAt !== before[id]?.clearedAt) void getStore().ensureOpen(id).catch(() => {});
}
}
if (event.key !== OFFLINE_ACCOUNTS_KEY && event.key !== null) return;
@@ -1105,16 +1171,27 @@ export function startOfflineMode(): () => void {
cleanups.push(onAccountRemoved((id, reason) => {
if (reason === 'explicit') void wipeAccount(id);
}));
void sweepAccountsGone();
void checkStorageAccess();
// Session, then a sync, for every account signed in with offline mode on.
// A client that just became real is what the waiting mail was waiting for:
// the flush at the start may have run before it was connected.
const flushedFor = new WeakSet<object>();
const syncSignedIn = () => {
applyDefaultOn();
let connected = false;
for (const account of useOfflineStore.getState().enabled) {
const client = onlineClientFor(account);
if (!client || !offlineActive(account)) continue;
void getStore().ensureOpen(account).then(() => getStore().refresh(account)).catch(() => {});
scheduleSync(account, 3_000);
if (!flushedFor.has(client)) {
flushedFor.add(client);
if (!isOfflineNow(client.getServerUrl())) connected = true;
}
}
if (connected) scheduleFlushAll(1_000);
};
cleanups.push(useAuthStore.subscribe((state, prev) => {
if (state.client !== prev.client || state.connectedAccountsRevision !== prev.connectedAccountsRevision) syncSignedIn();
@@ -1124,10 +1201,17 @@ export function startOfflineMode(): () => void {
const interval = setInterval(() => {
for (const account of useOfflineStore.getState().enabled) scheduleSync(account, 0);
}, SYNC_INTERVAL_MS);
const reconnect = setInterval(() => { void leaveOfflineStart(); }, RECONNECT_MS);
const reconnect = setInterval(() => {
void leaveOfflineStart();
// Nothing else tells a server that was down is back: a client that thinks
// it's down has no reason to ask it. A passing probe starts the flush.
for (const server of useOfflineStore.getState().downServers) void probeServer(server);
}, RECONNECT_MS);
cleanups.push(() => {
clearInterval(interval);
clearInterval(reconnect);
if (staleCheck) clearTimeout(staleCheck);
staleCheck = null;
});
if (useOfflineStore.getState().enabled.length > 0) {
@@ -1156,26 +1240,76 @@ export function startOfflineMode(): () => void {
};
}
/**
* Data no signed-in account vouches for. A dropped account's waits 30 days. One
* that left the registry on purpose before this ran (a login without "remember
* me" ends at a reload, before the host mounts) goes now, naming lost mail.
*/
async function sweepAccountsGone(now = Date.now()): Promise<void> {
const evicted = readEvicted();
const accounts = useAccountStore.getState().accounts;
for (const account of useOfflineStore.getState().enabled) {
const at = evicted[account];
const gone = at !== undefined ? now - at > EVICTED_KEEP_MS : !accounts.some((entry) => entry.id === account);
if (gone) await wipeAccount(account).catch(() => {});
}
}
let staleCheck: ReturnType<typeof setTimeout> | null = null;
/** A send taken by a tab that closed on the way: nothing else comes back to it. */
function checkStaleSendsLater(): void {
if (staleCheck) return;
staleCheck = setTimeout(() => {
staleCheck = null;
void refreshQueueState().then(() => scheduleFlushAll(0));
}, SENDING_STALE_MS + 1_000);
}
/** Firefox's private windows refuse this at once, where the worker would try for half a minute. */
async function checkStorageAccess(): Promise<void> {
try {
await navigator.storage.getDirectory();
} catch (error) {
if ((error as Error)?.name === 'SecurityError') useOfflineStore.setState({ storageBlocked: true });
}
}
let strandedNoted = false;
/** An earlier version's unsent mail, in a browser that can't run what would move it. */
async function noteStrandedMail(): Promise<void> {
if (strandedNoted || IS_LITE || !hasIndexedDb()) return;
if ((await strandedLegacyMessages().catch(() => 0)) === 0) return;
strandedNoted = true;
notify({ kind: 'legacy-stranded' });
}
/** How often the store catches up while the tab is open, on top of pushes. */
export const SYNC_INTERVAL_MS = 15 * 60 * 1000;
// A full sign-out deletes the store (lib/sign-out-cleanup.ts); forget what
// is held in memory as well.
onFullSignOut(() => {
onFullSignOut(({ keepLocalStore }) => {
for (const timer of syncTimers.values()) clearTimeout(timer);
syncTimers.clear();
getStore().stop();
if (!keepLocalStore) getStore().stop();
confirmed.clear();
named.clear();
useOfflineStore.setState({
enabled: [], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usageBytes: 0, usage: {}, choices: {}, health: {},
useOfflineStore.setState((s) => ({
enabled: keepLocalStore ? s.enabled : [],
choices: keepLocalStore ? s.choices : {},
health: keepLocalStore ? s.health : {},
outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usageBytes: 0, usage: {},
storageFull: {}, progress: {}, syncing: [], lastSyncedAt: {}, startedOffline: false,
});
}));
});
// Every sign-out the user didn't agree to lose mail at (the session limits,
// a refused token renewal, a removed account) names the unsent messages.
onBeforeSignOut(async (accountId) => {
onBeforeSignOut(async (accountId, { keepLocalStore }) => {
// Nothing is lost: it waits for the next sign-in.
if (keepLocalStore) return;
const stillSignedIn = accountId !== 'all' && useAccountStore.getState().accounts.some((a) => a.id !== accountId);
await nameLost(accountId, stillSignedIn);
});
@@ -1190,18 +1324,29 @@ export async function serverUnreachable(serverUrl: string): Promise<boolean> {
const SESSION_READ_TIMEOUT_MS = 5_000;
/** The session kept for an account, for starting from the local copy. */
export async function offlineSessionFor(account: string): Promise<unknown | null> {
/** A start doesn't hang on a black-holed fetch or a stuck database. */
const BOOT_WAIT_MS = 3_000;
function withinBootWait(work: Promise<unknown>): Promise<unknown> {
return Promise.race([work.catch(() => {}), new Promise((resolve) => setTimeout(resolve, BOOT_WAIT_MS))]);
}
export async function offlineSessionFor(account: string, options: { withFolders?: boolean } = {}): Promise<unknown | null> {
// The organisation's last word: the policy kept from the last fetch (the
// fetch itself fails without a network), never the defaults.
const policy = usePolicyStore.getState();
if (!policy.loaded && !policy.fetching) await policy.fetchPolicy();
else await whenPolicySettled(3000);
if (!offlineActive(account)) return null;
await withinBootWait(!policy.loaded && !policy.fetching ? policy.fetchPolicy() : whenPolicySettled(BOOT_WAIT_MS));
// Still nothing to go on: the usual start, which has its own errors.
if (!usePolicyStore.getState().loaded || !offlineActive(account)) return null;
registerOfflineHooks();
try {
await moveLegacyOutbox();
// What's in the old database goes on moving behind the start.
await withinBootWait(moveLegacyOutbox());
return await Promise.race([
getStore().area<unknown>(account, 'mail.session'),
(async () => {
if (options.withFolders && !(await getStore().area<boolean>(account, 'mail.hasFolders'))) return null;
return getStore().area<unknown>(account, 'mail.session');
})(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), SESSION_READ_TIMEOUT_MS)),
]);
} catch {
+9 -4
View File
@@ -9,7 +9,7 @@
import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync';
import { getStore } from '@/lib/store/client/api';
import type { ConflictSummary, OutboxError, OutboxStatus, OutboxSummary, PolicyFingerprint, SendItem, StoredConflict, StoredOutboxEntry } from '@/lib/store/outbox/items';
import { useOfflineStore } from './state';
import { visibleAccounts } from './state';
export { LOCAL_BLOB_PREFIX, heldBlobIds, isLocalBlobId } from '@/lib/store/outbox/items';
export type { EmailSendItem, OutboxError, OutboxStatus, OutboxSummary, PolicyFingerprint, RawSendItem, SendItem } from '@/lib/store/outbox/items';
@@ -39,10 +39,15 @@ interface StoredQueue {
}
/** Everything waiting for one account (or every account with it on), oldest first. */
export async function readQueue(account?: string): Promise<{ patches: number; patchesBy: Record<string, number>; outbox: OutboxEntry[]; conflicts: ConflictEntry[] }> {
const accounts = account ? [account] : useOfflineStore.getState().enabled;
const read = await Promise.all(accounts.map(async (id) => ({ id, queue: await getStore().area<StoredQueue>(id, 'outbox.read') })));
export async function readQueue(account?: string): Promise<{ failed: string[]; patches: number; patchesBy: Record<string, number>; outbox: OutboxEntry[]; conflicts: ConflictEntry[] }> {
const accounts = account ? [account] : visibleAccounts();
const settled = await Promise.allSettled(accounts.map(async (id) => ({ id, queue: await getStore().area<StoredQueue>(id, 'outbox.read') })));
const read = settled.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []));
const failed = accounts.filter((_, index) => settled[index].status === 'rejected');
// Nothing could be read: that isn't an empty queue.
if (read.length === 0 && failed.length > 0) throw (settled[0] as PromiseRejectedResult).reason;
return {
failed,
patches: read.reduce((sum, { queue }) => sum + queue.patches, 0),
patchesBy: Object.fromEntries(read.map(({ id, queue }) => [id, queue.patches])),
outbox: read.flatMap(({ id, queue }) => queue.entries.map((entry) => ({ ...entry, account: id }))),
+36 -6
View File
@@ -5,6 +5,7 @@
* forbids, and that includes the desktop app's default of keeping mail.
*/
import { getDesktopBridge, isDesktopApp } from '@/lib/desktop/bridge';
import { DEFAULT_SCOPE, type StoreScope } from '@/lib/store/area';
import type { DevicePolicy } from '@/lib/device-policy';
import { useOfflineStore, type UserChoice } from './state';
@@ -17,10 +18,12 @@ export const WINDOW_CHOICES: readonly (number | null)[] = [30, 90, 365, null];
export const ATTACHMENT_CAP_CHOICES: readonly number[] = [5, 25, 100, 500];
/** What the old offline mode kept, for those who had it on before the local store. */
export const LEGACY_CHOICE: UserChoice = { on: true, days: 30, attachments: true, maxAttachmentMb: 5 };
export const LEGACY_CHOICE: UserChoice = { on: true, days: 30, attachments: true, maxAttachmentBytes: 5 * MB };
export interface PlatformDefault {
on: boolean;
/** The OS policy says so either way (desktop `OfflineEnabled`, forced): no user choice applies. */
forced?: boolean | null;
scope: StoreScope;
}
@@ -32,6 +35,8 @@ export interface EffectiveScope {
/** What the policy decides, for the settings page: these show "Set by your organisation". */
locked: {
offline: boolean;
/** The switch itself: forced on or off by the OS policy. */
forced: boolean;
days: boolean;
attachments: boolean;
maxAttachment: boolean;
@@ -39,13 +44,37 @@ export interface EffectiveScope {
};
}
/** The desktop app keeps mail without asking. Browsers stay opt-in. Feature-detected: the bridge is only there in the app. */
interface OfflineEnabledSetting {
value?: unknown;
locked?: unknown;
source?: unknown;
}
/** `OfflineEnabled` from the desktop app's OS policies. Feature-detected: older shells have no such member. */
function offlineEnabledPolicy(): OfflineEnabledSetting | null {
const bridge = getDesktopBridge() as { policy?: { get?: () => { OfflineEnabled?: OfflineEnabledSetting } | null | undefined } } | null;
try {
return bridge?.policy?.get?.()?.OfflineEnabled ?? null;
} catch {
return null;
}
}
/**
* The desktop app keeps mail without asking, unless a recommended policy says
* otherwise. Browsers stay opt-in. Feature-detected: the bridge is only there in the app.
*/
export function offlineDefaultOn(): boolean {
return typeof window !== 'undefined' && Boolean((window as { bulwarkDesktop?: unknown }).bulwarkDesktop);
if (!isDesktopApp()) return false;
const setting = offlineEnabledPolicy();
if (setting && setting.source !== 'default' && typeof setting.value === 'boolean') return setting.value;
return true;
}
export function platformDefault(): PlatformDefault {
return { on: offlineDefaultOn(), scope: DEFAULT_SCOPE };
const setting = offlineEnabledPolicy();
const forced = setting?.locked && typeof setting.value === 'boolean' ? setting.value : null;
return { on: offlineDefaultOn(), forced, scope: DEFAULT_SCOPE };
}
/** Null means everything, so any cap is lower. */
@@ -66,10 +95,10 @@ export function effectiveScope(
): EffectiveScope {
const choice = userChoice ?? {};
const days = choice.days !== undefined ? choice.days : defaults.scope.days;
const maxAttachment = choice.maxAttachmentMb !== undefined ? choice.maxAttachmentMb * MB : defaults.scope.maxAttachmentBytes;
const maxAttachment = choice.maxAttachmentBytes ?? defaults.scope.maxAttachmentBytes;
return {
account,
enabled: !policy.noOffline && (choice.on ?? defaults.on),
enabled: !policy.noOffline && (defaults.forced ?? choice.on ?? defaults.on),
scope: {
days: cappedDays(days, policy.offlineMaxDays),
attachments: !policy.noOfflineAttachments && (choice.attachments ?? defaults.scope.attachments),
@@ -78,6 +107,7 @@ export function effectiveScope(
},
locked: {
offline: policy.noOffline,
forced: typeof defaults.forced === 'boolean',
days: policy.offlineMaxDays > 0,
attachments: policy.noOfflineAttachments,
maxAttachment: policy.offlineMaxAttachmentMb > 0,
+1 -1
View File
@@ -8,7 +8,7 @@ import { apiFetch, withBasePath } from '@/lib/browser-navigation';
import { currentBuildId, SW_SCOPE } from '@/lib/app-update';
/** The app's pages kept for a start without the network. */
const SHELL_PAGES = ['/', '/mail', '/settings', '/protocol/mailto', '/protocol/webcal'];
const SHELL_PAGES = ['/', '/mail', '/settings', '/protocol/mailto', '/protocol/webcal', '/popout/compose', '/popout/message/_'];
/**
* The files of this tab's build. After a deploy the server lists the new
+92 -17
View File
@@ -7,36 +7,32 @@
*/
import { create } from 'zustand';
import type { StoreUsage } from '@/lib/store/area';
import { DEFAULT_SCOPE, type StoreScope, type StoreUsage } from '@/lib/store/area';
import type { ConflictEntry, OutboxEntry, OutboxError } from './queue';
import type { PersistState } from './storage-health';
import type { SyncProgress } from './sync-progress';
/** localStorage: the local account ids that keep mail on this device. */
export const OFFLINE_ACCOUNTS_KEY = 'bulwark-offline-accounts';
/** localStorage: what the user picked per account. Numbers and switches only. */
export const OFFLINE_CHOICES_KEY = 'bulwark-offline-choices';
/** localStorage: what each account chose to keep offline, on top of the defaults. Numbers and switches only. */
export const OFFLINE_SCOPE_KEY = 'bulwark-offline-scope';
/** localStorage: what's known about each account's store with the store gone. Numbers only, never content. */
export const OFFLINE_HEALTH_KEY = 'bulwark-offline-health';
export interface UserChoice {
export interface UserChoice extends Partial<Pick<StoreScope, 'days' | 'attachments' | 'maxAttachmentBytes'>> {
/** Off here means the user turned it off: the desktop app's default doesn't turn it back on. */
on?: boolean;
/** Days of mail to keep. Null keeps everything. */
days?: number | null;
attachments?: boolean;
maxAttachmentMb?: number;
}
export interface AccountHealth {
/** The store was opened for this account once. */
opened?: boolean;
/** Unsent messages and changes last counted: what an eviction loses. */
pending?: number;
/** When the last sync finished. Survives a reload, unlike the running state. */
lastSync?: number;
/** The browser deleted what was kept. Stays until the user has read it. */
evicted?: { at: number; lost: number };
/** The store came back empty (see `storeLost`). Unlike that, this stays across a reload until the user has read it. */
reset?: { at: number; lost: number };
/** The user cleared the account: another tab that owns the store opens the new one. */
clearedAt?: number;
}
/** A calendar or contact edit that didn't go through. Lives in its own area's store. */
@@ -71,6 +67,13 @@ interface OfflineState {
downServers: string[];
/** The app started without the server, from the mail on this device. */
startedOffline: boolean;
/** The browser won't hand out the private file system here: a private window, say. */
storageBlocked: boolean;
/**
* Accounts whose store came back empty since this page loaded: the browser
* cleared their key or file. Whatever warns about eviction can go by this.
*/
storeLost: string[];
outbox: OutboxRow[];
pendingChanges: number;
/** Changes the server refused, or that met a message changed on another device. */
@@ -126,13 +129,15 @@ export const useOfflineStore = create<OfflineState>()(() => ({
browserOffline: typeof navigator !== 'undefined' && navigator.onLine === false,
downServers: [],
startedOffline: false,
storeLost: [],
storageBlocked: false,
outbox: [],
pendingChanges: 0,
conflicts: [],
areaConflicts: [],
usageBytes: 0,
usage: {},
choices: readRecord<UserChoice>(OFFLINE_CHOICES_KEY),
choices: readRecord<UserChoice>(OFFLINE_SCOPE_KEY),
health: readRecord<AccountHealth>(OFFLINE_HEALTH_KEY),
persist: 'unknown',
storageFull: {},
@@ -144,7 +149,7 @@ export const useOfflineStore = create<OfflineState>()(() => ({
export function writeChoice(account: string, patch: UserChoice): void {
const choices = { ...useOfflineStore.getState().choices, [account]: { ...useOfflineStore.getState().choices[account], ...patch } };
useOfflineStore.setState({ choices });
writeRecord(OFFLINE_CHOICES_KEY, choices);
writeRecord(OFFLINE_SCOPE_KEY, choices);
}
export function forgetAccountState(account: string): void {
@@ -152,7 +157,7 @@ export function forgetAccountState(account: string): void {
const without = <T,>(record: Record<string, T>) => Object.fromEntries(Object.entries(record).filter(([id]) => id !== account));
const next = { choices: without(choices), health: without(health), usage: without(usage), storageFull: without(storageFull), progress: without(progress) };
useOfflineStore.setState(next);
writeRecord(OFFLINE_CHOICES_KEY, next.choices);
writeRecord(OFFLINE_SCOPE_KEY, next.choices);
writeRecord(OFFLINE_HEALTH_KEY, next.health);
}
@@ -167,7 +172,7 @@ export function writeHealth(account: string, patch: AccountHealth): void {
/** The state of other tabs, after a storage event. */
export function readStoredChoices(): Record<string, UserChoice> {
return readRecord<UserChoice>(OFFLINE_CHOICES_KEY);
return readRecord<UserChoice>(OFFLINE_SCOPE_KEY);
}
export function readStoredHealth(): Record<string, AccountHealth> {
@@ -177,7 +182,7 @@ export function readStoredHealth(): Record<string, AccountHealth> {
/** Nothing is kept for anyone any more. */
export function forgetAllAccountState(): void {
useOfflineStore.setState({ choices: {}, health: {}, usage: {}, storageFull: {}, progress: {}, areaConflicts: [] });
writeRecord(OFFLINE_CHOICES_KEY, {});
writeRecord(OFFLINE_SCOPE_KEY, {});
writeRecord(OFFLINE_HEALTH_KEY, {});
}
@@ -191,6 +196,76 @@ export function writeEnabled(enabled: string[]): void {
}
}
/** What the user chose, over the defaults. The organisation's caps come on top: scopeForAccount in offline-mode.ts. */
export function offlineScopeFor(account: string): StoreScope {
try {
const { on: _on, ...chosen } = (JSON.parse(localStorage.getItem(OFFLINE_SCOPE_KEY) ?? '{}') as Record<string, UserChoice>)[account] ?? {};
return { ...DEFAULT_SCOPE, ...chosen };
} catch {
return DEFAULT_SCOPE;
}
}
/** localStorage: accounts the app dropped by itself, and when. Their data waits for a new sign-in. */
export const OFFLINE_EVICTED_KEY = 'bulwark-offline-evicted';
/** How long that data waits. */
export const EVICTED_KEEP_MS = 30 * 24 * 60 * 60 * 1000;
export function readEvicted(): Record<string, number> {
try {
const parsed = JSON.parse(localStorage.getItem(OFFLINE_EVICTED_KEY) ?? '{}') as unknown;
return parsed && typeof parsed === 'object' ? parsed as Record<string, number> : {};
} catch {
return {};
}
}
function writeEvicted(evicted: Record<string, number>): void {
try {
if (Object.keys(evicted).length > 0) localStorage.setItem(OFFLINE_EVICTED_KEY, JSON.stringify(evicted));
else localStorage.removeItem(OFFLINE_EVICTED_KEY);
} catch {
/* storage unavailable */
}
}
/** Only an account that keeps mail here has anything to wait for. */
export function markEvicted(account: string, now = Date.now()): void {
if (useOfflineStore.getState().enabled.includes(account)) writeEvicted({ ...readEvicted(), [account]: now });
}
export function clearEvicted(account: string): void {
const evicted = readEvicted();
if (!(account in evicted)) return;
delete evicted[account];
writeEvicted(evicted);
}
export function isEvicted(account: string): boolean {
return account in readEvicted();
}
/** The accounts whose data may show here: not those waiting for a sign-in, which may be someone else's. */
export function visibleAccounts(): string[] {
const evicted = readEvicted();
return useOfflineStore.getState().enabled.filter((account) => !(account in evicted));
}
/**
* Drops the plain-text list kept in localStorage for a quick start (email-store)
* once the store can do that job. Until an account's first sync finishes it's
* what stays on screen, so turning the store on alone doesn't remove it.
*/
export function forgetMailSnapshot(account: string): void {
try {
const raw = localStorage.getItem('email-snapshot');
if (raw && (JSON.parse(raw) as { account?: unknown } | null)?.account !== account) return;
localStorage.removeItem('email-snapshot');
} catch {
localStorage.removeItem('email-snapshot');
}
}
export function serverKey(url: string): string {
return url.replace(/\/+$/, '').toLowerCase();
}
-10
View File
@@ -48,13 +48,3 @@ export function safariMayDelete(userAgent: string = typeof navigator === 'undefi
if (/iPhone|iPad|iPod/.test(userAgent)) return true;
return /Macintosh/.test(userAgent) && /Safari\//.test(userAgent) && !/Chrome|Chromium|Edg\/|OPR\//.test(userAgent);
}
/**
* Whether the browser took away what was kept for an account: its key record
* with no file, or no key where one was made. A file with no key can't be read
* by anyone and is swept without a word; if the account had been opened, that
* is the second case. On a first open neither applies.
*/
export function wasEvicted(state: { opened: boolean; hadKey: boolean; fileExisted: boolean }): boolean {
return state.hadKey ? !state.fileExisted : state.opened;
}
+272
View File
@@ -0,0 +1,272 @@
/**
* What the windows of this browser say to each other about pop-outs. A
* BroadcastChannel, because a window opened by the desktop bridge has no
* opener, and a pop-out outlives a reload of the window that opened it.
*
* Every same-origin page hears this channel, and that includes privileged
* plugin frames (untrusted ones have an opaque origin and don't). So no
* credential goes on it in the clear: session answers are sealed for the
* window that asked (lib/popout/sealed.ts), and a refused credential is named
* by its fingerprint. Draft text does go on it, as it does for the plugins that
* see drafts anyway.
*/
import type { ComposerDraftData } from '@/components/email/email-composer';
import type { MessagePriority } from '@/lib/message-priority';
import type { ComposePopoutPayload } from './compose-payload';
import { canSealSessions, createSessionOpener, credentialFingerprint, type SealedAnswer } from './sealed';
export const POPOUT_CHANNEL = 'bulwark:popout-windows';
/** One connected login, as the window that holds it sees it. */
export interface SharedAccountSession {
accountId: string;
serverUrl: string;
username: string;
authMode: 'basic' | 'token' | 'oauth';
authHeader: string;
/** The window started offline: it has no credential to share (#523). */
offlineOnly?: boolean;
}
export interface SharedSessionAnswer {
activeAccountId: string | null;
sessions: SharedAccountSession[];
}
/** The send's outcome, for the main window to say and to offer Undo. The pop-out has closed by then. */
export interface PopoutSentNotice {
accountId: string | null;
scheduled?: boolean;
queued?: boolean;
filingError?: boolean;
rejectedRecipients?: string;
/** stores/email-store.ts PendingUndoSend. */
pendingUndoSend?: {
submissionId: string;
emailId?: string;
identityId?: string;
sendAt: string;
isSmime: boolean;
localAccountId?: string;
submissionAccountId?: string;
} | null;
}
/**
* A draft whose window is closing, ready to save. A request started while a
* window closes doesn't survive, and saving takes several, so another window
* does it. The arguments of JMAPClient.createDraft, as plain data.
*/
export interface RescuedDraft {
/** The login to save through; absent, the window's active one. */
localAccountId?: string;
to: string[];
subject: string;
body: string;
cc: string[];
bcc: string[];
identityId?: string;
fromEmail?: string;
/** The version this one replaces. */
draftId?: string;
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
fromName?: string;
htmlBody?: string;
inReplyTo?: string[];
references?: string[];
options?: { priority?: MessagePriority; previousDraftAccountId?: string; classification?: string | null };
/** The composer's state: where the text goes when the save fails. */
state: ComposerDraftData;
/** For a reload of that window to ask which draft id came of it. */
rescueKey?: string;
}
export type PopoutMessage =
| { type: 'session-request'; requestId: string; publicKey: JsonWebKey; accountId?: string; rejectedFingerprint?: string }
| { type: 'session-answer'; requestId: string; sealed: SealedAnswer }
| { type: 'handoff-request'; handoffId: string }
| { type: 'handoff-answer'; handoffId: string; payload: ComposePopoutPayload | null }
| { type: 'handoff-rendered'; handoffId: string }
| { type: 'sent'; requestId: string; notice: PopoutSentNotice }
| { type: 'sent-ack'; requestId: string }
| { type: 'mail-changed'; accountId: string | null }
| { type: 'state-change' }
| { type: 'draft-rescue'; draft: RescuedDraft }
| { type: 'rescue-query'; rescueKey: string }
| { type: 'rescue-answer'; rescueKey: string; draftId: string | null }
| { type: 'main-ping' }
| { type: 'main-pong'; windowId: string; accountIds: string[] }
| { type: 'main-gone'; windowId: string }
| { type: 'account-removed'; accountId: string };
export interface PopoutChannel {
post(message: PopoutMessage): void;
close(): void;
}
/** Null without BroadcastChannel: pop-outs aren't offered then (lib/popout/support.ts). */
export function openPopoutChannel(onMessage: (message: PopoutMessage) => void): PopoutChannel | null {
if (typeof BroadcastChannel === 'undefined') return null;
let channel: BroadcastChannel;
try {
channel = new BroadcastChannel(POPOUT_CHANNEL);
} catch {
return null;
}
channel.onmessage = (event: MessageEvent) => {
const message = event.data as PopoutMessage | null;
if (message && typeof message.type === 'string') onMessage(message);
};
return {
post(message) {
try {
channel.postMessage(message);
} catch {
// The channel is closed, or the page is going away.
}
},
close() {
channel.close();
},
};
}
function newId(): string {
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
export { newId as newPopoutId };
/** How long a pop-out waits for a main window before it goes on without one. */
export const SESSION_ANSWER_MS = 3000;
/**
* The session of a window that is signed in. With `accountId`, only a window
* holding that login answers, so the first answer is one that can be used.
* With `rejectedAuthHeader` the server just refused that credential: the main
* window renews it first when it still holds the same one. Null when no main
* window answers in time.
*/
export async function requestWindowSession(
options: { accountId?: string; rejectedAuthHeader?: string } = {},
timeoutMs: number = SESSION_ANSWER_MS,
): Promise<SharedSessionAnswer | null> {
if (!canSealSessions()) return null;
const opener = await createSessionOpener();
const rejectedFingerprint = options.rejectedAuthHeader ? await credentialFingerprint(options.rejectedAuthHeader) : undefined;
return new Promise((resolve) => {
const requestId = newId();
let timer: ReturnType<typeof setTimeout> | null = null;
const finish = (answer: SharedSessionAnswer | null) => {
if (timer) clearTimeout(timer);
channel?.close();
resolve(answer);
};
const channel = openPopoutChannel((message) => {
if (message.type !== 'session-answer' || message.requestId !== requestId) return;
void opener.open<SharedSessionAnswer>(message.sealed).then(finish, () => finish(null));
});
if (!channel) {
resolve(null);
return;
}
timer = setTimeout(() => finish(null), timeoutMs);
channel.post({
type: 'session-request',
requestId,
publicKey: opener.publicKey,
...(options.accountId ? { accountId: options.accountId } : {}),
...(rejectedFingerprint ? { rejectedFingerprint } : {}),
});
});
}
/** Tells a main window what was sent. True when one took it over. */
export function announceSentToMainWindow(notice: PopoutSentNotice, timeoutMs = 500): Promise<boolean> {
return new Promise((resolve) => {
const requestId = newId();
let timer: ReturnType<typeof setTimeout> | null = null;
const channel = openPopoutChannel((message) => {
if (message.type === 'sent-ack' && message.requestId === requestId) finish(true);
});
const finish = (taken: boolean) => {
if (timer) clearTimeout(timer);
channel?.close();
resolve(taken);
};
if (!channel) {
resolve(false);
return;
}
timer = setTimeout(() => finish(false), timeoutMs);
channel.post({ type: 'sent', requestId, notice });
});
}
/** Tells the main windows that this pop-out changed mail they may be showing. */
export function announceMailChanged(accountId: string | null): void {
const channel = openPopoutChannel(() => {});
channel?.post({ type: 'mail-changed', accountId });
channel?.close();
}
/**
* The draft id a closing window's text was saved under, for the same window
* after a reload, whose own copy still names the version that replaced. Null
* when no window saved anything for it.
*/
export function askRescuedDraftId(rescueKey: string, timeoutMs = 1500): Promise<string | null> {
return new Promise((resolve) => {
let timer: ReturnType<typeof setTimeout> | null = null;
const channel = openPopoutChannel((message) => {
if (message.type === 'rescue-answer' && message.rescueKey === rescueKey) finish(message.draftId);
});
const finish = (draftId: string | null) => {
if (timer) clearTimeout(timer);
channel?.close();
resolve(draftId);
};
if (!channel) {
resolve(null);
return;
}
timer = setTimeout(() => finish(null), timeoutMs);
channel.post({ type: 'rescue-query', rescueKey });
});
}
/** How often a pop-out checks that a main window is there, and how long an answer counts. */
const MAIN_PING_MS = 4000;
const MAIN_ALIVE_MS = 10_000;
/**
* Keeps track, synchronously, of which main windows are open and which logins
* they hold: a window closing has no time to ask. Also hands a closing draft to
* one that can save it.
*/
export function watchMainWindow(): { alive(accountId?: string): boolean; rescue(draft: RescuedDraft): boolean; stop(): void } {
const windows = new Map<string, { at: number; accountIds: string[] }>();
const channel = openPopoutChannel((message) => {
if (message.type === 'main-pong') windows.set(message.windowId, { at: Date.now(), accountIds: message.accountIds });
if (message.type === 'main-gone') windows.delete(message.windowId);
});
channel?.post({ type: 'main-ping' });
const timer = setInterval(() => channel?.post({ type: 'main-ping' }), MAIN_PING_MS);
const alive = (accountId?: string) => [...windows.values()].some((entry) =>
Date.now() - entry.at < MAIN_ALIVE_MS && (!accountId || entry.accountIds.includes(accountId)));
return {
alive,
rescue(draft) {
if (!channel || !alive(draft.localAccountId)) return false;
channel.post({ type: 'draft-rescue', draft });
return true;
},
stop() {
clearInterval(timer);
channel?.close();
},
};
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Moves a composer into a pop-out. The window has to open inside the click,
* but the draft is only ready once it is saved, so the opening window keeps
* a pending handoff that the pop-out asks for by id and waits on. The opening
* window lets go of its copy only once the pop-out says the composer is on
* screen, with the text in it.
*/
import { openPopoutChannel, newPopoutId, type PopoutChannel } from './channel';
import type { ComposePopoutPayload } from './compose-payload';
/** A handoff nobody asked for after this long is dropped. Above the time a save can take. */
const PENDING_TTL_MS = 120_000;
/** How long a pop-out waits for its draft. */
export const HANDOFF_WAIT_MS = 60_000;
/** How often a pop-out asks again: the request may cross a reload of the window that opened it. */
const HANDOFF_RETRY_MS = 1000;
/** How long the opening window waits for the composer to be on screen before it keeps the draft. */
export const HANDOFF_RENDER_WAIT_MS = 30_000;
const STORAGE_PREFIX = 'bulwark-popout-compose:';
interface Pending {
requested: boolean;
/** Undefined until the opening window decided; null means it won't come. */
payload?: ComposePopoutPayload | null;
rendered?: (shown: boolean) => void;
renderTimer?: ReturnType<typeof setTimeout>;
timer: ReturnType<typeof setTimeout>;
}
const pending = new Map<string, Pending>();
let channel: PopoutChannel | null = null;
function deliver(handoffId: string, entry: Pending): void {
if (!entry.requested || entry.payload === undefined) return;
channel?.post({ type: 'handoff-answer', handoffId, payload: entry.payload });
// Called off: nothing more to hear. Otherwise stay for "it is on screen".
if (entry.payload === null) forget(handoffId);
}
function forget(handoffId: string): void {
const entry = pending.get(handoffId);
if (entry) {
clearTimeout(entry.timer);
if (entry.renderTimer) clearTimeout(entry.renderTimer);
entry.rendered?.(false);
}
pending.delete(handoffId);
if (pending.size === 0) {
channel?.close();
channel = null;
}
}
export interface ComposeHandoff {
id: string;
/** Hands the draft over. True once the composer is on screen in the other window; false if that doesn't happen in time. */
complete(payload: ComposePopoutPayload, waitMs?: number): Promise<boolean>;
cancel(): void;
}
/** Null without BroadcastChannel. */
export function createComposeHandoff(): ComposeHandoff | null {
channel ??= openPopoutChannel((message) => {
if (message.type === 'handoff-request') {
const entry = pending.get(message.handoffId);
if (!entry) return;
entry.requested = true;
deliver(message.handoffId, entry);
} else if (message.type === 'handoff-rendered') {
const entry = pending.get(message.handoffId);
if (!entry) return;
entry.rendered?.(true);
entry.rendered = undefined;
forget(message.handoffId);
}
});
if (!channel) return null;
const id = newPopoutId();
pending.set(id, { requested: false, timer: setTimeout(() => forget(id), PENDING_TTL_MS) });
return {
id,
complete(payload, waitMs = HANDOFF_RENDER_WAIT_MS) {
const entry = pending.get(id);
if (!entry) return Promise.resolve(false);
return new Promise<boolean>((resolve) => {
entry.rendered = resolve;
entry.renderTimer = setTimeout(() => {
entry.rendered = undefined;
resolve(false);
}, waitMs);
entry.payload = payload;
deliver(id, entry);
});
},
cancel() {
const entry = pending.get(id);
// Also when the draft was delivered already: the pop-out is still listening.
if (!entry) return;
entry.payload = null;
entry.requested = true;
deliver(id, entry);
},
};
}
/** Keeps the draft with this window, so a reload starts from it. */
export function rememberComposePayload(handoffId: string, payload: ComposePopoutPayload): void {
try {
sessionStorage.setItem(STORAGE_PREFIX + handoffId, JSON.stringify(payload));
} catch {
// Full or unavailable: a reload then starts from the saved draft's first state.
}
}
/** The draft this window holds from before a reload. */
export function rememberedComposePayload(handoffId: string): ComposePopoutPayload | null {
try {
const raw = sessionStorage.getItem(STORAGE_PREFIX + handoffId);
return raw ? (JSON.parse(raw) as ComposePopoutPayload) : null;
} catch {
return null;
}
}
/**
* The pop-out's side. Null when the opening window called it off, or nothing
* came in time.
*/
export function receiveComposeHandoff(handoffId: string, timeoutMs: number = HANDOFF_WAIT_MS): Promise<ComposePopoutPayload | null> {
const remembered = rememberedComposePayload(handoffId);
if (remembered) return Promise.resolve(remembered);
return new Promise((resolve) => {
let retry: ReturnType<typeof setInterval> | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
const finish = (payload: ComposePopoutPayload | null) => {
if (retry) clearInterval(retry);
if (timer) clearTimeout(timer);
listener?.close();
resolve(payload);
};
const listener = openPopoutChannel((message) => {
if (message.type !== 'handoff-answer' || message.handoffId !== handoffId) return;
if (message.payload) rememberComposePayload(handoffId, message.payload);
finish(message.payload);
});
if (!listener) {
resolve(null);
return;
}
const ask = () => listener.post({ type: 'handoff-request', handoffId });
timer = setTimeout(() => finish(null), timeoutMs);
retry = setInterval(ask, HANDOFF_RETRY_MS);
ask();
});
}
/** The composer with the text is on screen here: the opening window can let go. */
export function acknowledgeComposeHandoff(handoffId: string): void {
const sender = openPopoutChannel(() => {});
sender?.post({ type: 'handoff-rendered', handoffId });
sender?.close();
}
/** Calls `onCancel` if the opening window takes the draft back. Returns the stop. */
export function watchHandoffCancel(handoffId: string, onCancel: () => void): () => void {
const listener = openPopoutChannel((message) => {
if (message.type === 'handoff-answer' && message.handoffId === handoffId && message.payload === null) onCancel();
});
return () => listener?.close();
}
+23
View File
@@ -0,0 +1,23 @@
/**
* What a composer needs to start in another window. Plain data: it crosses a
* BroadcastChannel and is kept in the pop-out's sessionStorage over a reload.
* Attachments are blob references, already on the server.
*/
import type { ComposerDraftData } from '@/components/email/email-composer';
import type { ComposePrefill } from '@/lib/plugin-compose-gate';
import type { EmailWriteSource } from '@/stores/email-store';
import type { ProComposerMode, ProReplyContext } from '@/stores/pro-tab-store';
export interface ComposePopoutPayload {
mode: ProComposerMode;
replyTo?: ProReplyContext;
initialDraftText?: string;
initialData?: ComposerDraftData | null;
prefill?: ComposePrefill | null;
/** The message replied to or forwarded, for its $answered / $forwarded flag. */
sourceEmailId?: string | null;
sourceEmailSource?: EmailWriteSource | null;
/** The mailbox a new message was started from; picks the identity (see the composer's prop). */
composeFromAccountEmail?: string;
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Opens pop-out windows: one message, or a composer. Shared with the desktop
* app's quick compose, which only has to call openComposerWindow.
*/
import { appPath } from '@/lib/deep-links';
import '@/lib/desktop/types';
import { createComposeHandoff, type ComposeHandoff } from './compose-handoff';
import type { ComposePopoutPayload } from './compose-payload';
const SIZES = {
message: { width: 960, height: 820 },
compose: { width: 760, height: 780 },
} as const;
type PopoutKind = keyof typeof SIZES;
/** Offset from the opening window, so the new one doesn't hide it exactly. */
const CASCADE_PX = 48;
export function popoutFeatures(kind: PopoutKind): string {
const { width, height } = SIZES[kind];
const left = Math.round((window.screenX || 0) + CASCADE_PX);
const top = Math.round((window.screenY || 0) + CASCADE_PX);
return `popup=yes,width=${width},height=${height},left=${left},top=${top}`;
}
/** Windows this page opened. window.open with a name would load the address in it again. */
const openedWindows = new Map<string, Window>();
/** Whether a window opened. False when a pop-up blocker said no: the caller stays as it is. */
function openWindow(url: string, name: string, kind: PopoutKind): Window | boolean {
const bridge = window.bulwarkDesktop?.windows;
if (bridge?.open) {
bridge.open(url, { name, ...SIZES[kind] });
return true;
}
const existing = openedWindows.get(name);
if (existing && !existing.closed) return existing;
// Not noopener: in an installed app that is what sends a window to the
// system browser (lib/standalone-display.ts).
const opened = window.open(url, name, popoutFeatures(kind));
if (!opened) return false;
openedWindows.set(name, opened);
return opened;
}
function focused(opened: Window | boolean): boolean {
if (opened && typeof opened !== 'boolean') opened.focus?.();
return !!opened;
}
function query(params: Record<string, string | null | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) if (value) search.set(key, value);
const text = search.toString();
return text ? `?${text}` : '';
}
export interface MessageWindowTarget {
emailId: string;
/** The login the message is read through. */
localAccountId?: string | null;
/** The JMAP account holding it, for a shared or group folder. */
ownerAccountId?: string | null;
}
export function messagePopoutUrl(target: MessageWindowTarget): string {
return appPath(`/popout/message/${encodeURIComponent(target.emailId)}`)
+ query({ account: target.localAccountId, owner: target.ownerAccountId });
}
/** The same message twice focuses the same window, without loading it again. */
export function openMessageWindow(target: MessageWindowTarget): boolean {
return focused(openWindow(messagePopoutUrl(target), `bulwark-message-${target.emailId.replace(/\W/g, '_')}`, 'message'));
}
export interface ComposerWindow {
/** Hands the draft over. True once the composer is on screen there; until then the sender keeps its copy. */
handOver(payload: ComposePopoutPayload): Promise<boolean>;
/** Calls it off: the pop-out closes. */
cancel(): void;
}
/**
* Opens an empty pop-out composer that waits for its draft. Call it straight
* from the click: a browser only opens a window for a user action. Null when
* no window opened.
*/
export function openComposerWindow(localAccountId?: string | null): ComposerWindow | null {
const handoff: ComposeHandoff | null = createComposeHandoff();
if (!handoff) return null;
const url = appPath('/popout/compose') + query({ handoff: handoff.id, account: localAccountId });
const opened = openWindow(url, `bulwark-compose-${handoff.id}`, 'compose');
if (!focused(opened)) {
handoff.cancel();
return null;
}
return { handOver: handoff.complete, cancel: handoff.cancel };
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Text from a closing pop-out that couldn't be saved. It waits here, kept over
* a reload, until the mail view can put it in its "Continue draft" slot.
*/
import type { ComposerDraftData } from '@/components/email/email-composer';
const STORAGE_KEY = 'bulwark-rescued-drafts';
const listeners = new Set<() => void>();
function read(): ComposerDraftData[] {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
const list: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(list) ? (list as ComposerDraftData[]) : [];
} catch {
return [];
}
}
function write(list: ComposerDraftData[]): void {
try {
if (list.length === 0) sessionStorage.removeItem(STORAGE_KEY);
else sessionStorage.setItem(STORAGE_KEY, JSON.stringify(list));
} catch {
// Full or unavailable: it stays for as long as this page does, below.
}
}
let memory: ComposerDraftData[] | null = null;
export function keepRescuedDraft(draft: ComposerDraftData): void {
memory = [...(memory ?? read()), { ...draft, unsaved: true }];
write(memory);
for (const listener of [...listeners]) listener();
}
/** The oldest waiting draft, taken off the list; null when there is none. */
export function takeRescuedDraft(): ComposerDraftData | null {
const list = memory ?? read();
if (list.length === 0) return null;
const [first, ...rest] = list;
memory = rest;
write(rest);
return first;
}
/** Calls `listener` when a draft comes in. Returns the unsubscribe. */
export function onRescuedDraft(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Credentials between windows, sealed for the window that asked. The request
* carries a throwaway public key, and the answer can only be opened with the
* private key, which is not extractable and never leaves that window. So a
* listener on the channel, such as a privileged plugin frame, hears no
* credential go by.
*
* A BroadcastChannel has no way to hand over a MessagePort, and a window the
* desktop bridge opens has no opener to send one through.
*/
export function canSealSessions(): boolean {
return typeof crypto !== 'undefined' && !!crypto.subtle;
}
const ECDH = { name: 'ECDH', namedCurve: 'P-256' } as const;
const AES = { name: 'AES-GCM', length: 256 } as const;
function toBase64(bytes: ArrayBuffer | Uint8Array): string {
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
let binary = '';
for (let i = 0; i < view.length; i++) binary += String.fromCharCode(view[i]);
return btoa(binary);
}
function fromBase64(text: string): Uint8Array<ArrayBuffer> {
const binary = atob(text);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
export interface SealedAnswer {
publicKey: JsonWebKey;
iv: string;
data: string;
}
export interface SessionOpener {
publicKey: JsonWebKey;
open<T>(sealed: SealedAnswer): Promise<T>;
}
async function deriveKey(privateKey: CryptoKey, peer: JsonWebKey): Promise<CryptoKey> {
const publicKey = await crypto.subtle.importKey('jwk', peer, ECDH, false, []);
return crypto.subtle.deriveKey({ name: 'ECDH', public: publicKey }, privateKey, AES, false, ['encrypt', 'decrypt']);
}
/** The asking window's side: a key to send along, and what opens the answer. */
export async function createSessionOpener(): Promise<SessionOpener> {
const pair = await crypto.subtle.generateKey(ECDH, false, ['deriveKey']);
const publicKey = await crypto.subtle.exportKey('jwk', pair.publicKey);
return {
publicKey,
async open<T>(sealed: SealedAnswer): Promise<T> {
const key = await deriveKey(pair.privateKey, sealed.publicKey);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromBase64(sealed.iv) }, key, fromBase64(sealed.data));
return JSON.parse(new TextDecoder().decode(plain)) as T;
},
};
}
/** The answering window's side. */
export async function sealFor(requester: JsonWebKey, value: unknown): Promise<SealedAnswer> {
const pair = await crypto.subtle.generateKey(ECDH, false, ['deriveKey']);
const key = await deriveKey(pair.privateKey, requester);
const iv = crypto.getRandomValues(new Uint8Array(12));
const data = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(JSON.stringify(value)));
return { publicKey: await crypto.subtle.exportKey('jwk', pair.publicKey), iv: toBase64(iv), data: toBase64(data) };
}
/** Says which credential the server refused without saying what it is. */
export async function credentialFingerprint(header: string): Promise<string> {
return toBase64(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(header)));
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Whether "Open in new window" is on offer: an installed app or the desktop
* app, on a device with windows. In a browser tab, a tab does the same job
* and the app behaves as it always has.
*/
import { IS_LITE } from '@/lib/lite';
import { isStandaloneDisplayMode } from '@/lib/standalone-display';
import '@/lib/desktop/types';
import { canSealSessions } from './sealed';
export function hasDesktopWindows(): boolean {
return typeof window !== 'undefined' && !!window.bulwarkDesktop?.windows;
}
/** Display mode is `browser` in Electron popups too, hence the bridge check. */
export function popoutsAvailable(): boolean {
// Lite has no such route: a static export can't list every message id.
if (IS_LITE || typeof window === 'undefined') return false;
// Sharing the session and renewing tokens safely across windows need these.
if (typeof BroadcastChannel === 'undefined' || !navigator.locks || !canSealSessions()) return false;
if (hasDesktopWindows()) return true;
if (!isStandaloneDisplayMode()) return false;
// A home-screen app on a phone or tablet has no second window: window.open
// leaves for the browser there.
if ((window.navigator as Navigator & { standalone?: boolean }).standalone === true) return false;
return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: fine)').matches;
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Whether this window is a pop-out (`/[locale]/popout/...`): a single message
* or a composer in its own window, without the rail or the sidebar.
*
* A pop-out is a guest of the main window's session. It never evicts an
* account, never starts a token timer and doesn't write the account list:
* stores/auth-store.ts and stores/account-store.ts ask here.
*/
import { withBasePath } from '@/lib/browser-navigation';
import { locales } from '@/i18n/routing';
const BUILD_BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
/** Path segments that are pages of their own, so a files path like /files/popout/compose isn't taken for a pop-out. */
const OTHER_SECTIONS = new Set(['mail', 'calendar', 'contacts', 'files', 'settings', 'apps', 'pro', 'admin', 'protocol', 'setup', 'connector', 'login', 'auth', 'plugins']);
export function isPopoutPath(pathname: string): boolean {
// The mount comes off first: an app served under /mail isn't on its mail page.
const inApp = BUILD_BASE_PATH && (pathname === BUILD_BASE_PATH || pathname.startsWith(`${BUILD_BASE_PATH}/`))
? pathname.slice(BUILD_BASE_PATH.length)
: pathname;
const segments = inApp.split('/').filter(Boolean);
const at = segments.indexOf('popout');
if (at < 0) return false;
// Before it: nothing, or a locale, with a mount only a reverse proxy knows ahead of that.
const before = segments.slice(0, at);
if (before.length > 0 && !(locales as readonly string[]).includes(before[before.length - 1])) return false;
if (before.some((segment) => OTHER_SECTIONS.has(segment))) return false;
const rest = segments.slice(at + 1);
return (rest.length === 1 && rest[0] === 'compose') || (rest.length === 2 && rest[0] === 'message');
}
export function isPopoutWindow(): boolean {
return typeof window !== 'undefined' && isPopoutPath(window.location.pathname);
}
/**
* localStorage for the stores a pop-out shares with the main window, without
* its writes: a pop-out on account B would otherwise switch the main window
* to B at its next reload, and write back a list older than the main
* window's. Throws on the server like a bare `localStorage` does, which
* createJSONStorage takes as "no storage".
*/
export function mainWindowStorage(): Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
if (typeof localStorage === 'undefined') throw new Error('No localStorage here');
return {
getItem: (key) => localStorage.getItem(key),
setItem: (key, value) => {
if (!isPopoutWindow()) localStorage.setItem(key, value);
},
removeItem: (key) => {
if (!isPopoutWindow()) localStorage.removeItem(key);
},
};
}
/**
* Closes the pop-out. A script may only close a window a script opened, so a
* pop-out address opened by hand goes to the app instead.
*/
export function closePopoutWindow(): void {
if (typeof window === 'undefined') return;
window.close();
window.setTimeout(() => {
if (!window.closed) window.location.replace(withBasePath('/'));
}, 300);
}
+2 -3
View File
@@ -1,3 +1,4 @@
import { isStandaloneDisplayMode } from "@/lib/standalone-display";
import type { ParsedMailto } from "./mailto";
import type { ParsedWebcal } from "./webcal";
import { matchSurface } from "@/lib/deep-links";
@@ -136,9 +137,7 @@ function getMailtoClientPriority(info: ProtocolClientInfo): number {
}
function getDefaultProtocolClientInfo(): ProtocolClientInfo {
const nav = navigator as Navigator & { standalone?: boolean };
const standalone = window.matchMedia?.("(display-mode: standalone)").matches || nav.standalone === true;
return { path: window.location.pathname, standalone, clientId: BROWSER_CLIENT_ID };
return { path: window.location.pathname, standalone: isStandaloneDisplayMode(), clientId: BROWSER_CLIENT_ID };
}
async function requestMailtoViaServiceWorker(value: ParsedMailto, timeoutMs: number): Promise<boolean> {
+33 -15
View File
@@ -36,28 +36,43 @@ const SIGNED_OUT_STORAGE_KEYS = [
// Offline mode's "Keep mail on this device" (lib/offline/state.ts): the
// next person signing in here starts with it off.
'bulwark-offline-accounts',
// Which accounts' data waits for a new sign-in, and until when.
'bulwark-offline-evicted',
'bulwark-store-opened',
// What each account chose to keep, and what's known about its store.
'bulwark-offline-choices',
'bulwark-offline-scope',
'bulwark-offline-health',
];
const LOCAL_STORE_KEYS = new Set(['bulwark-offline-accounts', 'bulwark-offline-evicted', 'bulwark-store-opened', 'bulwark-offline-scope', 'bulwark-offline-health']);
const SIGNED_OUT_SIGNAL = 'bulwark:signed-out';
export { SIGNED_OUT_SIGNAL as SIGNED_OUT_KEY };
// Stores the auth store can't import (they import it) clear themselves here.
const signedOutCleanups = new Set<() => void>();
const signedOutCleanups = new Set<(options: SignOutCleanupOptions) => void>();
export interface SignOutCleanupOptions {
/**
* Keep the local store and its keys. For a sign-out the app decided on
* because it couldn't confirm the login (an outage, an unknown refusal), or
* when an account that was dropped that way still has data here.
*/
keepLocalStore?: boolean;
}
/** Run `cleanup` when everyone signs out in this tab. */
export function onFullSignOut(cleanup: () => void): void {
export function onFullSignOut(cleanup: (options: SignOutCleanupOptions) => void): void {
signedOutCleanups.add(cleanup);
}
// Work that has to reach the server before an account signs out (changes
// still queued for it). The auth store waits for it within its sign-out
// budget, next to the plugins' onBeforeLogout.
const beforeSignOutTasks = new Set<(accountId: string) => Promise<void>>();
const beforeSignOutTasks = new Set<(accountId: string, options: SignOutCleanupOptions) => Promise<void>>();
/** Run `task` before an account signs out; `accountId` is 'all' when every account does. */
export function onBeforeSignOut(task: (accountId: string) => Promise<void>): () => void {
export function onBeforeSignOut(task: (accountId: string, options: SignOutCleanupOptions) => Promise<void>): () => void {
beforeSignOutTasks.add(task);
return () => beforeSignOutTasks.delete(task);
}
@@ -84,14 +99,15 @@ export async function passSignOutGates(accountId: string): Promise<boolean> {
return true;
}
export async function runBeforeSignOut(accountId: string): Promise<void> {
await Promise.all([...beforeSignOutTasks].map((task) => task(accountId).catch(() => {})));
export async function runBeforeSignOut(accountId: string, options: SignOutCleanupOptions = {}): Promise<void> {
await Promise.all([...beforeSignOutTasks].map((task) => task(accountId, options).catch(() => {})));
}
/** Remove the accounts' leftovers from browser storage. */
export function purgeSignedOutData(): void {
export function purgeSignedOutData(options: SignOutCleanupOptions = {}): void {
if (typeof window === 'undefined') return;
for (const key of SIGNED_OUT_STORAGE_KEYS) {
if (options.keepLocalStore && LOCAL_STORE_KEYS.has(key)) continue;
try {
window.localStorage.removeItem(key);
} catch {
@@ -114,18 +130,20 @@ export function purgeSignedOutData(): void {
// Offline mode (#523): the local store with the mail and the outbox, the keys
// it was encrypted with, and the app shell the service worker kept. Also the
// database of older versions, in case it wasn't moved yet.
void purgeStoreFiles();
try {
window.indexedDB?.deleteDatabase('bulwark-offline');
} catch {
/* storage unavailable */
if (!options.keepLocalStore) {
void purgeStoreFiles();
try {
window.indexedDB?.deleteDatabase('bulwark-offline');
} catch {
/* storage unavailable */
}
forgetOfflineShell();
}
forgetOfflineShell();
// Files and links on their way into a draft or an import.
clearHandoffs();
for (const cleanup of signedOutCleanups) {
try {
cleanup();
cleanup(options);
} catch {
/* one store failing must not keep the others' data */
}
-59
View File
@@ -9,25 +9,13 @@ import { openMemoryStore } from './helpers/node-adapter';
const KEY = 'a'.repeat(64);
function keyError(): Error {
const error = new Error('The local store could not be opened with this key');
error.name = 'StoreKeyError';
return error;
}
class FakeStorage implements HostStorage {
readonly dbs = new Map<string, StorageAdapter>();
failNext: Error | null = null;
removed: string[] = [];
files(): string[] {
return [...this.dbs.keys()];
}
async open(file: string): Promise<StorageAdapter> {
if (this.failNext) {
const error = this.failNext;
this.failNext = null;
throw error;
}
let db = this.dbs.get(file);
if (!db) {
db = openMemoryStore();
@@ -51,53 +39,6 @@ const params = (file: string, session: unknown = FAKE_SESSION, account = 'a') =>
account, file, keyHex: KEY, session, scope: DEFAULT_SCOPE, invertedKeywordSort: null, auth: null,
}) as Parameters<StoreHost['openAccount']>[0];
describe('opening an account', () => {
it('says whether its file was there', async () => {
const storage = new FakeStorage();
const first = host(storage);
expect(await first.openAccount(params('/a1.db'))).toEqual({ fileExisted: false });
expect(await first.openAccount(params('/a1.db'))).toEqual({ fileExisted: true });
await first.closeAll();
expect(await host(storage).openAccount(params('/a1.db'))).toEqual({ fileExisted: true });
});
it('makes no empty file while it has no session, so the next open can still tell', async () => {
const storage = new FakeStorage();
await expect(host(storage).openAccount(params('/a1.db', null))).rejects.toThrow('No session');
expect(storage.files()).toEqual([]);
expect(await host(storage).openAccount(params('/a1.db'))).toEqual({ fileExisted: false });
});
it('starts over from a file its key can\'t read, and says the file is gone', async () => {
const storage = new FakeStorage();
storage.dbs.set('/a1.db', openMemoryStore());
storage.failNext = keyError();
expect(await host(storage).openAccount(params('/a1.db'))).toEqual({ fileExisted: false });
expect(storage.removed).toEqual(['/a1.db']);
});
it('never deletes a file over any other error', async () => {
const storage = new FakeStorage();
storage.dbs.set('/a1.db', openMemoryStore());
storage.failNext = new Error('disk I/O error');
await expect(host(storage).openAccount(params('/a1.db'))).rejects.toThrow('disk I/O');
expect(storage.removed).toEqual([]);
});
});
describe('files with no key', () => {
it('are deleted, journals included; known ones and those in use stay', async () => {
const storage = new FakeStorage();
const running = host(storage);
await running.openAccount(params('/ainuse.db'));
storage.dbs.set('/aknown.db', openMemoryStore());
storage.dbs.set('/aorphan.db', openMemoryStore());
storage.dbs.set('/aorphan.db-journal', openMemoryStore());
expect(running.sweepFiles(['/aknown.db'])).toBeGreaterThan(0);
expect(storage.files().sort()).toEqual(['/ainuse.db', '/aknown.db']);
});
});
describe('removing an account', () => {
it('tells every tab, once the file is gone', async () => {
const storage = new FakeStorage();
+97
View File
@@ -263,6 +263,51 @@ describe('Email/query filters and sorts', () => {
});
});
describe('a store without its mail yet', () => {
it('says so rather than answering an empty list, until the first sync has finished', async () => {
const server = new FakeJmapMail();
server.addEmail({ id: 'one' });
const { engine } = engineFor(server);
const inbox = Object.keys((server.emails.get('one')!.mailboxIds) as Record<string, boolean>)[0];
const query = () => one(engine, ['Email/query', { filter: { inMailbox: inbox } }, 'q']);
// No answer: the client reports it can't tell, and the list on screen stays.
expect(await query()).toBeUndefined();
await engine.requestSync();
expect((await query())![1].ids).toEqual(['one']);
// After a finished sync, an empty folder really is empty.
server.destroyEmail('one');
await engine.requestSync();
expect((await query())![1].ids).toEqual([]);
});
it('tells a start whether there are folders to show', async () => {
const server = new FakeJmapMail();
const { engine } = engineFor(server);
expect(await engine.areaCall('mail.hasFolders', {})).toBe(false);
await engine.requestSync();
expect(await engine.areaCall('mail.hasFolders', {})).toBe(true);
});
});
describe('keyword case, as measured on both Stalwart versions', () => {
it('keeps two keywords differing only in case, and sync doesn\'t stop on them', async () => {
const server = new FakeJmapMail();
server.addEmail({ id: 'both', keywords: { Tag: true, tag: true, $seen: true } });
server.addEmail({ id: 'upper', keywords: { Tag: true, $SEEN: true, $seen: true } });
server.addEmail({ id: 'none', keywords: {} });
const { engine, db } = engineFor(server);
await engine.requestSync();
expect(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM email')?.n).toBe(3);
const has = async (keyword: string) => (await one(engine, ['Email/query', { filter: { hasKeyword: keyword }, sort: [{ property: 'receivedAt', isAscending: false }] }, 'q']))![1].ids;
expect(await has('Tag')).toEqual(['upper', 'both']);
expect(await has('tag')).toEqual(['both']);
expect(await has('TAG')).toEqual([]);
expect(await has('$SEEN')).toEqual(['upper', 'both']);
const [, counts] = (await one(engine, ['Keyword/get', { ids: ['Tag', 'tag', '$SEEN'] }, 'k']))!;
expect(counts.list).toEqual([{ id: 'Tag', total: 2, unread: 0 }, { id: 'tag', total: 1, unread: 0 }, { id: '$SEEN', total: 2, unread: 0 }]);
});
});
describe('the rest of the mail methods', () => {
it('answers Core/echo with what it was given', async () => {
const { engine } = engineFor(new FakeJmapMail());
@@ -426,6 +471,58 @@ describe('attachments', () => {
expect(refused.downloads).toEqual(['b-gone']);
});
it.each([500, 503, 429, 401])('tries a file again after a %i, which says nothing about the file', async (status) => {
class Failing extends Files {
fails = true;
override async download(jmap?: string, blobId?: string): Promise<Uint8Array> {
if (this.fails) throw new TransportError('http', 'Nope', status);
return super.download(jmap, blobId);
}
}
const server = new Failing();
server.addEmail({ id: 'm', attachments: [{ blobId: 'b', size: 10, type: 'image/png' }] });
const { engine, db } = engineFor(server);
await engine.requestSync();
expect(db.get<{ refused: number }>('SELECT refused FROM email_part')?.refused).toBe(0);
server.fails = false;
server.addEmail({ id: 'n' });
await engine.requestSync();
expect(await engine.areaCall('mail.getBlob', { jmap: JMAP_ACCOUNT, blobId: 'b' })).not.toBeNull();
});
it.each([403, 404, 410])('gives up on a file after a %i', async (status) => {
class Refusing extends Files {
override async download(): Promise<Uint8Array> {
throw new TransportError('http', 'Nope', status);
}
}
const server = new Refusing();
server.addEmail({ id: 'm', attachments: [{ blobId: 'b', size: 10, type: 'image/png' }] });
const { engine, db } = engineFor(server);
await engine.requestSync();
expect(db.get<{ refused: number }>('SELECT refused FROM email_part')?.refused).toBe(1);
});
it('files a blob the user opened only as far as the chosen scope allows', async () => {
const server = new Files();
server.addEmail({ id: 'm', attachments: [{ blobId: 'b-kept', size: 10, type: 'image/png' }] });
const data = new Uint8Array(10);
const record = { jmap: JMAP_ACCOUNT, blobId: 'b-kept', type: 'image/png', data };
const none = engineFor(server, { ...EVERYTHING, attachments: false });
await none.engine.requestSync();
expect(await none.engine.areaCall('mail.recordBlob', record)).toBe(false);
expect(await none.engine.areaCall('mail.getBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-kept' })).toBeNull();
const small = engineFor(server, { ...EVERYTHING, attachments: true, maxAttachmentBytes: 9 });
await small.engine.requestSync();
expect(await small.engine.areaCall('mail.recordBlob', record)).toBe(false);
const fits = engineFor(server, { ...EVERYTHING, attachments: true, maxAttachmentBytes: 10 });
await fits.engine.requestSync();
expect(await fits.engine.areaCall('mail.recordBlob', record)).toBe(true);
});
it('files a blob the user opened only under a kept message: never a stray file', async () => {
const server = new Files();
server.addEmail({ id: 'm', attachments: [{ blobId: 'b-kept', size: 99_999_999, type: 'image/png' }] });
+89 -15
View File
@@ -6,6 +6,7 @@ import { TransportError, type MethodCall } from '../engine/transport';
import { mailArea } from '../mail';
import { outboxArea } from '../outbox';
import { CLOCK_SKEW_MARGIN_MS, decideRetry, LOOKUP_TRUSTED_MS, type LookupArgs } from '../outbox/dedupe';
import { addHeldFileSource } from '../outbox/calls';
import { flushPatches, type PatchOutcome, type PendingPatch } from '../outbox/flush-patches';
import type { StoredConflict, StoredOutboxEntry } from '../outbox/items';
import { mergePatches, patchConflicts } from '../outbox/patches';
@@ -34,7 +35,7 @@ function setup() {
transport: server,
areas: [mailArea, outboxArea],
scope: { ...DEFAULT_SCOPE, days: null },
now: () => NOW,
now: () => clock,
sleep: async () => {},
});
return { server, db, engine };
@@ -43,6 +44,7 @@ function setup() {
let t: ReturnType<typeof setup>;
beforeEach(async () => {
clock = NOW;
t = setup();
await t.engine.requestSync();
});
@@ -57,14 +59,15 @@ async function local(id: string) {
return answer?.methodResponses[0][1].list[0];
}
let clock = NOW;
async function boxes() {
const answer = await t.engine.answer([['Mailbox/get', {}, 'm']]);
return Object.fromEntries((answer?.methodResponses[0][1].list as Array<{ id: string; totalEmails: number; unreadEmails: number }>).map((box) => [box.id, [box.totalEmails, box.unreadEmails]]));
}
const io = (server: FakeJmapMail = t.server, isRefusal: (error: unknown) => boolean = () => false) => ({
const io = (server: FakeJmapMail = t.server) => ({
request: (calls: MethodCall[]) => server.request(calls),
isRefusal,
});
async function flush(ioOverride = io()) {
@@ -319,9 +322,18 @@ describe('conflicts', () => {
expect((await local('a'))?.subject).toBe('Aye');
});
it('compares per property, ignoring case in keyword names', () => {
const base = { keywords: { '$Label:A': true }, mailboxIds: { x: true } };
expect(patchConflicts({ 'keywords/$label:a': null }, base, { keywords: { '$label:a': true }, mailboxIds: { x: true } })).toBe(false);
it('compares per property, keywords the way the server does', () => {
const base = { keywords: { '$SEEN': true, '$label:A': true }, mailboxIds: { x: true } };
// System flags fold.
expect(patchConflicts({ 'keywords/$seen': null }, base, { keywords: { $seen: true, '$label:A': true }, mailboxIds: { x: true } })).toBe(false);
// Custom keywords are case-sensitive: the server now has another one.
expect(patchConflicts({ 'keywords/$label:A': null }, base, { keywords: { '$label:a': true }, mailboxIds: { x: true } })).toBe(false);
expect(patchConflicts({ 'keywords/$label:a': true }, { keywords: {}, mailboxIds: {} }, { keywords: { '$label:A': true }, mailboxIds: {} })).toBe(false);
expect(patchConflicts({ 'keywords/$label:A': true }, { keywords: { '$label:A': true }, mailboxIds: {} }, { keywords: { '$label:a': true }, mailboxIds: {} })).toBe(true);
});
it('compares per property', () => {
const base = { keywords: { '$label:a': true }, mailboxIds: { x: true } };
expect(patchConflicts({ 'keywords/$seen': true }, base, { keywords: {}, mailboxIds: { x: true } })).toBe(false);
expect(patchConflicts({ keywords: { $seen: true } }, base, { keywords: {}, mailboxIds: { x: true } })).toBe(true);
expect(patchConflicts({ keywords: { $seen: true } }, base, { keywords: { $seen: true }, mailboxIds: { x: true } })).toBe(false);
@@ -349,16 +361,54 @@ describe('when the server can\'t be reached during a flush', () => {
expect(await pending()).toHaveLength(1);
});
it('turns a request the server refuses for good into a conflict, not a stuck queue', async () => {
/** Refuses the Email/set with this answer, and reads go through. */
const refusingSet = (message: string) => ({
request: async (calls: MethodCall[]) => {
if (calls[0][0] === 'Email/set') throw new Error(message);
return t.server.request(calls);
},
});
const PROBLEM = 'Request failed: 400 - {"type":"urn:ietf:params:jmap:error:notRequest","status":400}';
it('turns a request the server refuses for good into a conflict, and shows what the server has', async () => {
await update({ a: { 'keywords/$seen': true } });
const refusing = {
request: async () => { throw new Error('Request failed: 400 - bad'); },
isRefusal: (error: unknown) => /^Request failed: 400/.test((error as Error).message),
};
const { outcomes, finished } = await flush(refusing);
const { outcomes, finished } = await flush(refusingSet(PROBLEM));
expect(finished).toBe(true);
expect(outcomes[0]).toMatchObject({ outcome: 'conflict', reason: 'refused' });
expect(await pending()).toEqual([]);
// The change is gone, and so is its effect here.
const answer = await t.engine.answer([['Email/get', { ids: ['a'], properties: ['keywords'] }, 'g']]);
expect(answer?.methodResponses[0][1].list[0].keywords).toEqual({});
expect((await read()).conflicts).toHaveLength(1);
});
it.each([
['the parallel-request limit', 'Request failed: 400 - {"type":"urn:ietf:params:jmap:error:limit","limit":"maxConcurrentRequests"}'],
['a proxy\'s error page', 'Request failed: 403 - <html>Forbidden</html>'],
['a body that\'s no JMAP problem', 'Request failed: 413 - Payload too large'],
['a server error', 'Request failed: 500 - oops'],
])('keeps the change for later on %s', async (_name, message) => {
await update({ a: { 'keywords/$seen': true } });
const { outcomes, finished } = await flush(refusingSet(message));
expect(finished).toBe(false);
expect(outcomes).toEqual([]);
expect(await pending()).toHaveLength(1);
expect((await read()).conflicts).toEqual([]);
});
it('keeps the change for later when the read after a refusal fails', async () => {
await update({ a: { 'keywords/$seen': true } });
let calls = 0;
const { finished } = await flush({
request: async (batch: MethodCall[]) => {
calls += 1;
if (calls === 1) return t.server.request(batch);
if (batch[0][0] === 'Email/set') throw new Error(PROBLEM);
throw new TypeError('Failed to fetch');
},
});
expect(finished).toBe(false);
expect(await pending()).toHaveLength(1);
});
it('keeps a change made while the flush ran, and doesn\'t compare it with the state the flush caused', async () => {
@@ -384,7 +434,7 @@ describe('sends', () => {
expect(await t.engine.areaCall('outbox.claim', { id })).toBe(true);
expect(await t.engine.areaCall('outbox.claim', { id })).toBe(false);
const entry = (await read()).entries[0];
expect(entry).toMatchObject({ status: 'sending', sendingSince: NOW, firstAttemptAt: NOW, messageId: 'fixed@example.org', item });
expect(entry).toMatchObject({ status: 'sending', sendingSince: NOW, lastAttemptAt: NOW, messageId: 'fixed@example.org', item });
});
it('marks what a closed tab left behind uncertain', async () => {
@@ -393,15 +443,30 @@ describe('sends', () => {
await t.engine.areaCall('outbox.settleStale', { before: NOW });
expect((await read()).entries[0].status).toBe('sending');
await t.engine.areaCall('outbox.settleStale', { before: NOW + 1 });
expect((await read()).entries[0].status).toBe('uncertain');
expect((await read()).entries[0]).toMatchObject({ status: 'uncertain', firstAttemptAt: NOW });
});
it('keeps the first attempt time across retries, so the lookup covers the first copy', async () => {
const id = await t.engine.areaCall('outbox.queueSend', { summary, item }) as number;
await t.engine.areaCall('outbox.claim', { id });
await t.engine.areaCall('outbox.update', { id, status: 'uncertain' });
expect((await read()).entries[0].firstAttemptAt).toBe(NOW);
clock = NOW + 60_000;
await t.engine.areaCall('outbox.update', { id, status: 'waiting' });
await t.engine.areaCall('outbox.claim', { id });
expect((await read()).entries[0].firstAttemptAt).toBe(NOW);
await t.engine.areaCall('outbox.update', { id, status: 'uncertain' });
const entry = (await read()).entries[0];
expect(entry.firstAttemptAt).toBe(NOW);
expect(entry.lastAttemptAt).toBe(NOW + 60_000);
});
it('doesn\'t call a try that never started an attempt', async () => {
const id = await t.engine.areaCall('outbox.queueSend', { summary, item }) as number;
for (let i = 0; i < 2; i += 1) {
await t.engine.areaCall('outbox.claim', { id });
await t.engine.areaCall('outbox.update', { id, status: 'waiting' });
}
expect((await read()).entries[0].firstAttemptAt).toBeUndefined();
});
it('keeps order across changes and sends, and a later merge moves a change later', async () => {
@@ -430,6 +495,15 @@ describe('sends', () => {
expect((await read()).entries).toEqual([]);
});
it('leaves a file alone that another area holds', async () => {
const held = await t.engine.areaCall('outbox.holdFile', { name: 'draft.txt', type: 'text/plain', data: new Uint8Array(1) }) as { blobId: string };
const stale = await t.engine.areaCall('outbox.holdFile', { name: 'old.txt', type: 'text/plain', data: new Uint8Array(1) }) as { blobId: string };
addHeldFileSource(() => [held.blobId]);
expect(await t.engine.areaCall('outbox.dropOrphans', { olderThan: NOW + 1 })).toBe(1);
expect(await t.engine.areaCall('outbox.heldFile', { blobId: held.blobId })).not.toBeNull();
expect(await t.engine.areaCall('outbox.heldFile', { blobId: stale.blobId })).toBeNull();
});
it('drops old files nothing needs', async () => {
const data = new Uint8Array([1]);
const orphan = await t.engine.areaCall('outbox.holdFile', { name: 'a', type: '', data }) as { blobId: string };
+151 -6
View File
@@ -1,9 +1,10 @@
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { configureStore, getStore, setStoreBackend } from '../client/api';
import { getAccountRecord } from '../client/keys';
import { configureStore, getStore, setStoreBackend, STORE_OPENED_KEY } from '../client/api';
import { forgetAccountKey, getAccountRecord } from '../client/keys';
import { purgeStoreFiles } from '../client/purge';
import { StoreClient } from '../client/store-client';
import { StoreClient, StoreOwnerGoneError } from '../client/store-client';
import { storeFilesToPrune } from '../worker/host';
import { FAKE_SESSION } from './helpers/fake-jmap';
/** Everything on one page: a BroadcastChannel that also reaches other objects here, as in a browser. */
@@ -25,7 +26,7 @@ class FakeChannel {
class FakeWorker {
static all: FakeWorker[] = [];
static opened = { fileExisted: true };
static openResult: unknown;
onmessage: ((event: MessageEvent) => void) | null = null;
posted: Array<Record<string, unknown>> = [];
terminated = false;
@@ -34,7 +35,7 @@ class FakeWorker {
}
postMessage(message: Record<string, unknown>): void {
this.posted.push(message);
if (message.kind === 'open') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: FakeWorker.opened } } as MessageEvent));
if (message.kind === 'open' || message.kind === 'prune-files') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: message.kind === 'open' ? FakeWorker.openResult : undefined } } as MessageEvent));
}
terminate(): void {
this.terminated = true;
@@ -108,6 +109,47 @@ describe('a tab that starts after the owner', () => {
});
});
describe('an owner that stopped answering', () => {
afterEach(() => vi.useRealTimers());
async function clientWithOwner(answers: () => boolean) {
vi.useFakeTimers();
const owner = new FakeChannel('bulwark-store-rpc');
owner.onmessage = (event) => {
const data = event.data as { kind: string };
if (data.kind === 'who-owns' && answers()) owner.postMessage({ kind: 'owner-changed', owner: 'elsewhere' });
};
const client = new StoreClient({ basePath: '' });
client.start();
await vi.advanceTimersByTimeAsync(0);
return client;
}
it('stops its calls waiting, long before their own time limit', async () => {
let answering = true;
const client = await clientWithOwner(() => answering);
const call = client.sync('a@x');
const failed = vi.fn();
void call.catch(failed);
await vi.advanceTimersByTimeAsync(0);
answering = false;
await vi.advanceTimersByTimeAsync(39_000);
expect(failed).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(11_000);
expect(failed.mock.calls[0][0]).toBeInstanceOf(StoreOwnerGoneError);
client.halt();
});
it('leaves calls alone while the owner answers', async () => {
const client = await clientWithOwner(() => true);
const failed = vi.fn();
void client.sync('a@x').catch(failed);
await vi.advanceTimersByTimeAsync(10 * 60_000);
expect(failed).not.toHaveBeenCalled();
client.halt();
});
});
describe('the owner tab calling its own worker', () => {
it('waits for the worker on the channel, since a call posted before that is lost', async () => {
const worker = new FakeChannel('bulwark-store-rpc');
@@ -166,7 +208,7 @@ describe('purgeStoreFiles', () => {
const order: string[] = [];
Object.defineProperty(window.navigator, 'locks', {
configurable: true,
value: { request: async (name: string, run: () => Promise<void>) => { order.push(`lock ${name}`); await run(); order.push('unlock'); } },
value: { request: async (name: string, _options: unknown, run: () => Promise<void>) => { order.push(`lock ${name}`); await run(); order.push('unlock'); } },
});
storage(async (name, options) => {
order.push(`remove ${name} ${JSON.stringify(options)}`);
@@ -177,6 +219,25 @@ describe('purgeStoreFiles', () => {
expect((await indexedDB.databases()).map((d) => d.name)).not.toContain('bulwark-store');
});
it('deletes what it can when another tab never lets go of the owner lock', async () => {
const remove = vi.fn(async () => {});
storage(remove);
let waited: AbortSignal | undefined;
Object.defineProperty(window.navigator, 'locks', {
configurable: true,
value: {
// The browser gives up when the signal says so.
request: async (_name: string, options: { signal: AbortSignal }) => {
waited = options.signal;
throw new DOMException('Took too long', 'TimeoutError');
},
},
});
await purgeStoreFiles();
expect(waited).toBeInstanceOf(AbortSignal);
expect(remove).toHaveBeenCalledTimes(1);
});
it('waits out a worker that still holds the files, and shrugs at a pool that isn\'t there', async () => {
const remove = vi.fn()
.mockRejectedValueOnce(Object.assign(new Error('busy'), { name: 'NoModificationAllowedError' }))
@@ -190,6 +251,12 @@ describe('purgeStoreFiles', () => {
});
});
describe('which files have no key', () => {
it('names store files with no key, and leaves anything else alone', () => {
expect(storeFilesToPrune(['/a01.db', '/a02.db', '/a02.db-journal', '/other', '/ab.DB'], ['/a02.db'])).toEqual(['/a01.db']);
});
});
describe('the page\'s store', () => {
function allowEngine() {
configureStore({
@@ -215,6 +282,84 @@ describe('the page\'s store', () => {
expect(worker.terminated).toBe(true);
});
describe('when the key or the file of a store is gone', () => {
const lost = vi.fn();
const open = async () => {
configureStore({
inputs: () => ({ session: FAKE_SESSION, auth: 'Basic x', invertedKeywordSort: true }),
refreshAuth: async () => null,
onEvent: () => {},
onStoreLost: lost,
});
const store = getStore();
await store.ensureOpen('lost@x');
const worker = FakeWorker.all[FakeWorker.all.length - 1];
worker.becomeOwner();
await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'open')).toBe(true));
await vi.waitFor(() => expect(localStorage.getItem(STORE_OPENED_KEY)).toContain('lost@x'));
store.stop();
return worker;
};
beforeEach(() => {
lost.mockClear();
localStorage.clear();
});
afterEach(async () => {
await forgetAccountKey('lost@x');
});
it('stays quiet about a first open, and removes nothing a key still names', async () => {
const worker = await open();
expect(lost).not.toHaveBeenCalled();
const prune = worker.posted.find((m) => m.kind === 'prune-files') as unknown as { keep: string[] };
const record = await getAccountRecord('lost@x');
expect(prune.keep).toContain(record!.file);
});
it('deletes the orphan and tells the page when the key went', async () => {
await open();
const before = await getAccountRecord('lost@x');
await forgetAccountKey('lost@x');
const worker = await open();
const after = await getAccountRecord('lost@x');
expect(after!.file).not.toBe(before!.file);
const prune = worker.posted.find((m) => m.kind === 'prune-files') as unknown as { keep: string[] };
expect(prune.keep).toContain(after!.file);
expect(prune.keep).not.toContain(before!.file);
expect(lost).toHaveBeenCalledWith('lost@x');
});
it('stays quiet on a normal reopen', async () => {
await open();
const worker = await open();
expect(lost).not.toHaveBeenCalled();
expect(worker.posted.some((m) => m.kind === 'prune-files')).toBe(false);
});
it('tells the page when the file went and the key stayed', async () => {
await open();
FakeWorker.openResult = { created: true };
try {
await open();
} finally {
FakeWorker.openResult = undefined;
}
expect(lost).toHaveBeenCalledWith('lost@x');
});
it('doesn\'t remember an account that was removed', async () => {
await open();
vi.spyOn(StoreClient.prototype, 'removeAccount').mockResolvedValue(true as never);
await getStore().remove('lost@x');
getStore().stop();
vi.restoreAllMocks();
await open();
expect(lost).not.toHaveBeenCalled();
});
});
it('runs the client\'s refresh when the worker needs auth, and hands the result over', async () => {
allowEngine();
const store = getStore();
+6
View File
@@ -117,6 +117,10 @@ class WasmAdapter implements StorageAdapter {
}
}
export function installMayPass(error: unknown): boolean {
return (error as Error)?.name !== 'SecurityError';
}
/** The module and the pool, once. Only the owner gets here. */
export class WasmStorage {
private constructor(private readonly sqlite3: any, private readonly pool: Pool) {}
@@ -141,6 +145,8 @@ export class WasmStorage {
}) as Pool;
return new WasmStorage(sqlite3, pool);
} catch (error) {
// The browser won't allow it here at all, a private window: waiting won't help.
if (!installMayPass(error)) throw error;
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 100 + attempt * 50));
}
+2 -2
View File
@@ -16,9 +16,9 @@ export const calendarArea: StoreArea = {
},
calls: {
'calendar.flush': (ctx) => flushCalendar(ctx),
'calendar.conflicts': (ctx) => listConflicts(ctx.db),
/** How many writes wait to go out. */
/** Edits the server hasn't got, for the question before a sign-out. */
'calendar.pending': (ctx) => Number(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_pending')?.n ?? 0),
'calendar.conflicts': (ctx) => listConflicts(ctx.db),
'calendar.resolveStandIns': (ctx, params: { ids: string[] }) => resolveStandIns(ctx.db, params.ids),
'calendar.dismissConflict': (ctx, params: { id: number }) => {
ctx.db.run('DELETE FROM calendar_conflict WHERE id = ?', [params.id]);
+47 -19
View File
@@ -7,7 +7,7 @@
import type { StoreEvent, StoreScope } from '../area';
import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport';
import { forgetAccountKey, getAccountRecord, listAccountRecords, openAccountKey, type WrapLayer } from './keys';
import { forgetAccountKey, getAccountRecord, knownStoreFiles, openAccountKey, type WrapLayer } from './keys';
import { StoreClient } from './store-client';
export interface StoreAccountInputs {
@@ -24,10 +24,10 @@ export interface StorePageHooks {
/** Runs the client's refresh path; the fresh Authorization header, or null. */
refreshAuth(account: string): Promise<string | null>;
onEvent(event: StoreEvent): void;
/** What opening an account found: its key from before, and its file. Owner tab only. */
onOpened?(account: string, found: { hadKey: boolean; fileExisted: boolean }): void;
/** Wrap layers on top of the WebCrypto one (desktop keychain, app lock). */
keyLayers?(): readonly WrapLayer[];
/** An account this device kept mail for has an empty store now: its key or its file went. */
onStoreLost?(account: string): void;
}
/** What lib/offline uses. A seam, so its tests run the real engine on node:sqlite. */
@@ -54,12 +54,38 @@ export interface StoreBackend {
const OPEN_RETRIES = 25;
const OPEN_RETRY_MS = 200;
/**
* localStorage: accounts that have had a store here. Holds nothing but the
* account's id, and it outlives what a browser may clear of the rest.
*/
export const STORE_OPENED_KEY = 'bulwark-store-opened';
function readOpenedAccounts(): string[] {
try {
const parsed = JSON.parse(localStorage.getItem(STORE_OPENED_KEY) ?? '[]') as unknown;
return Array.isArray(parsed) ? parsed as string[] : [];
} catch {
return [];
}
}
function rememberOpened(account: string, opened: boolean): void {
try {
const others = readOpenedAccounts().filter((id) => id !== account);
localStorage.setItem(STORE_OPENED_KEY, JSON.stringify(opened ? [...others, account] : others));
} catch {
// Without it a lost store goes unnoticed, and the store still works.
}
}
class PageStore implements StoreBackend {
private client: StoreClient | null = null;
private readonly wanted = new Map<string, StoreSession | null>();
private readonly opening = new Map<string, Promise<void>>();
/** The engine can't start in this browser: don't keep every caller waiting for an owner. */
private broken = false;
/** What the owner's engine was last told to keep. */
private readonly sentScope = new Map<string, string>();
constructor(private readonly hooks: () => StorePageHooks | null) {}
@@ -82,7 +108,6 @@ class PageStore implements StoreBackend {
onOwner: () => {
this.opening.clear();
for (const account of this.wanted.keys()) void this.openHere(account).catch(() => {});
void this.sweepOrphans();
},
onNeedAuth: (account) => {
void (this.hooks()?.refreshAuth(account) ?? Promise.resolve(null))
@@ -98,31 +123,24 @@ class PageStore implements StoreBackend {
private reset(): void {
this.client = null;
this.sentScope.clear();
this.wanted.clear();
this.opening.clear();
this.broken = false;
}
/** Files whose key is gone can't be read by anyone: they only take room. */
private async sweepOrphans(): Promise<void> {
try {
const records = await listAccountRecords();
await this.client?.sweepFiles(records.map((record) => record.file));
} catch {
// Tried again the next time this tab or another becomes the owner.
}
}
/** Only the owner may open: the key goes to this tab's own worker, never over the channel. */
private openHere(account: string): Promise<void> {
const running = this.opening.get(account);
if (running) return running;
const run = (async () => {
const client = this.started();
const hadKey = Boolean(await getAccountRecord(account));
const { keyHex, record } = await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []);
const { keyHex, record, fresh } = await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []);
// The old file, if the key alone went, can never be read again.
if (fresh) await client.pruneFiles(await knownStoreFiles()).catch(() => {});
const inputs = this.hooks()?.inputs(account) ?? { session: null, auth: null, invertedKeywordSort: null };
const { fileExisted } = await client.openAccount({
if (inputs.scope) this.sentScope.set(account, JSON.stringify(inputs.scope));
const opened = await client.openAccount({
account,
file: record.file,
keyHex,
@@ -131,7 +149,10 @@ class PageStore implements StoreBackend {
invertedKeywordSort: inputs.invertedKeywordSort,
auth: inputs.auth,
});
this.hooks()?.onOpened?.(account, { hadKey, fileExisted });
if (fresh || opened?.created) {
if (readOpenedAccounts().includes(account)) this.hooks()?.onStoreLost?.(account);
}
rememberOpened(account, true);
})();
this.opening.set(account, run);
run.catch(() => this.opening.delete(account));
@@ -152,7 +173,12 @@ class PageStore implements StoreBackend {
if (!inputs) return;
if (inputs.session) client.setSession(account, inputs.session);
client.setAuth(account, inputs.auth);
if (inputs.scope) void client.setScope(account, inputs.scope).catch(() => {});
// Only a real change: setting it starts over what a full disk made the store give up on.
const scope = inputs.scope ? JSON.stringify(inputs.scope) : null;
if (inputs.scope && scope !== this.sentScope.get(account)) {
if (this.sentScope.has(account)) void client.setScope(account, inputs.scope).catch(() => {});
this.sentScope.set(account, scope!);
}
if (inputs.invertedKeywordSort !== null) client.setInvertedKeywordSort(account, inputs.invertedKeywordSort);
}
@@ -179,6 +205,7 @@ class PageStore implements StoreBackend {
}
setScope(account: string, scope: StoreScope): Promise<void> {
this.sentScope.set(account, JSON.stringify(scope));
return this.whenOpen(account, (client) => client.setScope(account, scope));
}
@@ -198,6 +225,7 @@ class PageStore implements StoreBackend {
async remove(account: string): Promise<void> {
const record = await getAccountRecord(account);
await forgetAccountKey(account);
rememberOpened(account, false);
this.wanted.delete(account);
this.opening.delete(account);
if (record && !this.broken) await this.started().removeAccount(account, record.file);
+11 -3
View File
@@ -83,6 +83,14 @@ export function newStoreFileName(): string {
return `/a${toHex(crypto.getRandomValues(new Uint8Array(8)))}.db`;
}
/** The file of every account with a key. Any other file in the pool can't be read by anyone. */
export async function knownStoreFiles(): Promise<string[]> {
return withDb(async (db) => {
const records = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).getAll()) as AccountRecord[];
return records.map((record) => record.file);
});
}
export async function getAccountRecord(account: string): Promise<AccountRecord | null> {
return withDb(async (db) => {
const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined;
@@ -95,7 +103,7 @@ export async function listAccountRecords(): Promise<AccountRecord[]> {
}
/** The account's key in hex, created on first use. Returns the record too, for the file name. */
export async function openAccountKey(account: string, layers: readonly WrapLayer[] = []): Promise<{ keyHex: string; record: AccountRecord }> {
export async function openAccountKey(account: string, layers: readonly WrapLayer[] = []): Promise<{ keyHex: string; record: AccountRecord; fresh: boolean }> {
return withDb(async (db) => {
const wrapping = await wrapKey(db);
const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined;
@@ -109,7 +117,7 @@ export async function openAccountKey(account: string, layers: readonly WrapLayer
const raw = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: asBuffer(found.iv) }, wrapping, asBuffer(data)));
const keyHex = toHex(raw);
raw.fill(0);
return { keyHex, record: found };
return { keyHex, record: found, fresh: false };
}
const raw = crypto.getRandomValues(new Uint8Array(32));
const iv = crypto.getRandomValues(new Uint8Array(12));
@@ -126,7 +134,7 @@ export async function openAccountKey(account: string, layers: readonly WrapLayer
await promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).put(record));
const keyHex = toHex(raw);
raw.fill(0);
return { keyHex, record };
return { keyHex, record, fresh: true };
});
}
+14 -2
View File
@@ -10,6 +10,8 @@ import { deleteKeyDatabase } from './keys';
/** A worker just stopped may hold the files for a moment longer. */
const REMOVE_TRIES = 10;
const REMOVE_WAIT_MS = 200;
/** A sign-out doesn't wait for a tab that never lets go of the owner lock. */
const POOL_LOCK_WAIT_MS = 5_000;
async function removePool(): Promise<void> {
const root = await navigator.storage.getDirectory();
@@ -28,8 +30,18 @@ async function removePool(): Promise<void> {
/** Deletes the pool folder, holding the owner lock so no worker can take the files meanwhile. */
export async function deleteStorePool(): Promise<void> {
if (typeof navigator === 'undefined' || typeof navigator.storage?.getDirectory !== 'function') return;
if (navigator.locks) await navigator.locks.request(OWNER_LOCK, removePool);
else await removePool();
if (!navigator.locks) {
await removePool();
return;
}
try {
await navigator.locks.request(OWNER_LOCK, { signal: AbortSignal.timeout(POOL_LOCK_WAIT_MS) }, removePool);
} catch (error) {
if ((error as Error)?.name !== 'TimeoutError' && (error as Error)?.name !== 'AbortError') throw error;
// A tab that won't let go, stuck or asleep: the key is gone, so what can be
// deleted goes now, and what it still holds can't be read anyway.
await removePool();
}
}
/**
+48 -3
View File
@@ -21,6 +21,9 @@ import {
const CALL_TIMEOUT_MS = 60_000;
const OWNER_WAIT_MS = 15_000;
/** Calls waiting on the owner ask it this often whether it's still there. */
const OWNER_PING_MS = 10_000;
const OWNER_PING_MISSES = 3;
/** A first sync of a large account runs a long time, and its promise is what ends "syncing". */
const SYNC_TIMEOUT_MS = 30 * 60_000;
@@ -58,6 +61,8 @@ export class StoreClient {
private isOwner = false;
private readonly pending = new Map<string, Pending>();
private readonly ownerWaiters: Array<() => void> = [];
private ownerWatch: ReturnType<typeof setInterval> | null = null;
private ownerAnswered: (() => void) | null = null;
private lifecycleBound = false;
constructor(private readonly options: StoreClientOptions = {}) {}
@@ -150,6 +155,7 @@ export class StoreClient {
break;
case 'owner-changed':
this.owner = message.owner;
if (message.owner) this.ownerAnswered?.();
if (message.owner) this.flushOwnerWaiters();
break;
case 'shutdown':
@@ -175,6 +181,41 @@ export class StoreClient {
}
}
/**
* A tab that's frozen or stuck keeps the lock, and nothing else would tell.
* While calls wait, the owner is asked to answer; when it doesn't for a while,
* they stop waiting instead of running into their long time limits.
*/
private watchOwner(): void {
if (this.ownerWatch) return;
let unanswered = 0;
this.ownerAnswered = () => { unanswered = 0; };
this.ownerWatch = setInterval(() => {
if (this.pending.size === 0) {
this.stopWatchingOwner();
return;
}
if (unanswered >= OWNER_PING_MISSES) {
this.owner = null;
this.stopWatchingOwner();
for (const [id, pending] of this.pending) {
this.pending.delete(id);
clearTimeout(pending.timer);
pending.reject(new StoreOwnerGoneError());
}
return;
}
unanswered += 1;
this.channel?.postMessage({ kind: 'who-owns' } satisfies ChannelMessage);
}, OWNER_PING_MS);
}
private stopWatchingOwner(): void {
if (this.ownerWatch) clearInterval(this.ownerWatch);
this.ownerWatch = null;
this.ownerAnswered = null;
}
private flushOwnerWaiters(): void {
for (const waiter of this.ownerWaiters.splice(0)) waiter();
}
@@ -202,6 +243,7 @@ export class StoreClient {
private async rpc<K extends keyof StoreRpc>(method: K, params: Parameters<StoreRpc[K]>[0], timeoutMs = CALL_TIMEOUT_MS): Promise<ReturnType<StoreRpc[K]>> {
await this.waitForOwner();
this.watchOwner();
const id = crypto.randomUUID();
const result = this.track<ReturnType<StoreRpc[K]>>(id, timeoutMs);
this.channel?.postMessage({ kind: 'rpc', id, from: this.clientId, method, params } as ChannelMessage);
@@ -220,12 +262,14 @@ export class StoreClient {
return this.isOwner;
}
openAccount(params: OpenAccountParams): Promise<{ fileExisted: boolean }> {
/** `created`: there was no database under this name. */
openAccount(params: OpenAccountParams): Promise<{ created?: boolean } | undefined> {
return this.direct({ kind: 'open', id: crypto.randomUUID(), params });
}
sweepFiles(known: string[]): Promise<number> {
return this.direct({ kind: 'sweep-files', id: crypto.randomUUID(), known });
/** Deletes every store file but these. */
pruneFiles(keep: string[]): Promise<void> {
return this.direct({ kind: 'prune-files', id: crypto.randomUUID(), keep });
}
closeAccount(account: string): Promise<void> {
@@ -266,6 +310,7 @@ export class StoreClient {
this.channel = null;
this.isOwner = false;
this.owner = null;
this.stopWatchingOwner();
for (const [id, pending] of this.pending) {
this.pending.delete(id);
clearTimeout(pending.timer);
+2
View File
@@ -62,4 +62,6 @@ export const MAIL_CALLS: Readonly<Record<string, AreaCall>> = {
'mail.usage': usage,
/** The session kept with the account, for a start from the local copy. */
'mail.session': (ctx) => ctx.session,
/** Whether a start may show the store: it has a folder list from the server. */
'mail.hasFolders': (ctx) => ctx.db.get("SELECT 1 AS yes FROM sync_state WHERE type = 'Mailbox' AND state IS NOT NULL") !== undefined,
};
+9 -5
View File
@@ -8,6 +8,7 @@ import { MethodError, type MethodContext, type MethodHandler, type MethodResult
import { listObjects } from '../engine/core-schema';
import type { SqlValue } from '../storage';
import { headerValue, parseHeaderProperty, type RawHeader } from './headers';
import { keywordKey } from './records';
import { limitTextConditions, searchSnippetGet, textCondition, TEXT_FIELDS, type TextField } from './search';
type Filter = Record<string, unknown>;
@@ -62,11 +63,11 @@ function filterSql(filter: Filter, jmap: string, drivenBy: string | null = null,
}
case 'hasKeyword':
conditions.push(`EXISTS (SELECT 1 FROM email_keyword k WHERE k.email_rowid = ${rowid} AND k.keyword = ?)`);
params.push(String(value).toLowerCase());
params.push(keywordKey(String(value)));
break;
case 'notKeyword':
conditions.push(`NOT EXISTS (SELECT 1 FROM email_keyword k WHERE k.email_rowid = ${rowid} AND k.keyword = ?)`);
params.push(String(value).toLowerCase());
params.push(keywordKey(String(value)));
break;
case 'after':
conditions.push(`${receivedAt} >= ?`);
@@ -145,7 +146,7 @@ function orderSql(ctx: MethodContext, sort: Comparator[]): Sql {
// stored answer has to read it the way that server would.
if (ctx.invertedKeywordSort) ascending = !ascending;
parts.push(`EXISTS (SELECT 1 FROM email_keyword s WHERE s.email_rowid = e.rowid AND s.keyword = ?) ${ascending ? 'ASC' : 'DESC'}`);
params.push(String(level.keyword ?? '').toLowerCase());
params.push(keywordKey(String(level.keyword ?? '')));
break;
}
default:
@@ -185,7 +186,7 @@ function keywordLevels(ctx: MethodContext, sort: Comparator[]): KeywordLevel[] |
let next = 0;
for (; sort[next]?.property === 'hasKeyword'; next += 1) {
const ascending = sort[next].isAscending !== false;
levels.push({ keyword: String(sort[next].keyword ?? '').toLowerCase(), first: ctx.invertedKeywordSort ? ascending : !ascending });
levels.push({ keyword: keywordKey(String(sort[next].keyword ?? '')), first: ctx.invertedKeywordSort ? ascending : !ascending });
}
if (levels.length === 0 || levels.length > MAX_KEYWORD_LEVELS) return null;
return isNewestFirst(sort.slice(next)) ? levels : null;
@@ -316,6 +317,9 @@ const emailQuery: MethodHandler = (ctx, args) => {
).map((row) => String(row.id));
// Stalwart's queryState is the Email state. Absent until the first sync, as allowed.
const queryState = lastState(ctx, jmap, 'Email');
// Nothing here doesn't mean nothing there: an empty folder of a first sync
// that hasn't finished mustn't paint over what's on screen.
if (ids.length === 0 && position === 0 && !queryState) throw new MethodError('serverUnavailable', 'Mail not kept yet');
const result: MethodResult = {
accountId: jmap,
canCalculateChanges: false,
@@ -512,7 +516,7 @@ const keywordGet: MethodHandler = (ctx, args) => {
const counts = new Map(rows.map((row) => [String(row.keyword), { id: String(row.keyword), total: Number(row.total), unread: Number(row.unread) }]));
const wanted = Array.isArray(args.ids) ? args.ids as string[] : null;
const list = wanted
? wanted.map((id) => ({ ...(counts.get(id.toLowerCase()) ?? { total: 0, unread: 0 }), id }))
? wanted.map((id) => ({ ...(counts.get(keywordKey(id)) ?? { total: 0, unread: 0 }), id }))
: [...counts.values()];
return {
accountId: jmap,
+13 -1
View File
@@ -2,6 +2,18 @@
import type { StorageAdapter } from '../storage';
/**
* How keywords compare, measured on both Stalwart versions: the IMAP system
* flags fold to lower case, every other keyword is case-sensitive, so `Tag` and
* `tag` are two keywords and one message can carry both.
*/
const SYSTEM_KEYWORDS = new Set(['$seen', '$flagged', '$answered', '$draft', '$forwarded', '$mdnsent', '$junk', '$notjunk', '$phishing', '$recent', '$deleted']);
export function keywordKey(keyword: string): string {
const lower = keyword.toLowerCase();
return SYSTEM_KEYWORDS.has(lower) ? lower : keyword;
}
export const MAIL_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'];
export const EMAIL_LIST_PROPERTIES = [
@@ -80,7 +92,7 @@ export function upsertEmail(db: StorageAdapter, jmap: string, email: EmailObject
if (on) db.run('INSERT INTO email_mailbox (email_rowid, jmap, mailbox_id, received_at) VALUES (?, ?, ?, ?)', [rowid, jmap, box, receivedAt]);
}
for (const [keyword, on] of Object.entries((email.keywords ?? {}) as Record<string, boolean>)) {
if (on) db.run('INSERT INTO email_keyword (email_rowid, keyword) VALUES (?, ?)', [rowid, keyword.toLowerCase()]);
if (on) db.run('INSERT OR IGNORE INTO email_keyword (email_rowid, keyword) VALUES (?, ?)', [rowid, keywordKey(keyword)]);
}
return rowid;
}
+5 -1
View File
@@ -380,10 +380,12 @@ async function fetchAttachments(ctx: SyncContext, jmap: string): Promise<void> {
try {
data = await ctx.transport.download(jmap, blobId, part.type ?? 'application/octet-stream', part.name ?? 'attachment');
} catch (error) {
if (error instanceof TransportError && error.kind === 'http') {
// Only what says this file won't come. Anything else, like a 500 or a 429, is tried at the next sync.
if (error instanceof TransportError && error.kind === 'http' && PERMANENT_DOWNLOAD_STATUSES.has(error.status ?? 0)) {
ctx.db.run('UPDATE email_part SET refused = 1 WHERE jmap = ? AND blob_id = ?', [jmap, blobId]);
continue;
}
if (error instanceof TransportError && error.kind === 'http') return;
throw error;
}
putBlob(ctx.db, jmap, blobId, part.type, data, Number(part.received_at));
@@ -392,6 +394,8 @@ async function fetchAttachments(ctx: SyncContext, jmap: string): Promise<void> {
}
}
const PERMANENT_DOWNLOAD_STATUSES = new Set([403, 404, 410]);
// ---- Identities and quotas ----
const IDENTITY_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:submission'];
+16 -2
View File
@@ -27,6 +27,7 @@ interface OutboxRow {
status: string;
sending_since: number | null;
first_attempt_at: number | null;
last_attempt_at: number | null;
payload: string;
summary: string;
message_id: string | null;
@@ -46,6 +47,7 @@ function toEntry(row: OutboxRow): StoredOutboxEntry {
...(payload.fingerprint ? { fingerprint: payload.fingerprint } : {}),
...(row.sending_since ? { sendingSince: Number(row.sending_since) } : {}),
...(row.first_attempt_at ? { firstAttemptAt: Number(row.first_attempt_at) } : {}),
...(row.last_attempt_at ? { lastAttemptAt: Number(row.last_attempt_at) } : {}),
...(row.error ? { error: JSON.parse(row.error) as OutboxError } : {}),
...(row.message_id ? { messageId: row.message_id } : {}),
...(row.copy_id ? { copyId: row.copy_id } : {}),
@@ -101,7 +103,7 @@ function read(ctx: StoreContext): { patches: number; entries: StoredOutboxEntry[
function claim(ctx: StoreContext, params: { id: number }): boolean {
const now = ctx.now();
return ctx.db.run(
`UPDATE outbox SET status = 'sending', sending_since = ?, first_attempt_at = COALESCE(first_attempt_at, ?) WHERE id = ? AND status = 'waiting'`,
`UPDATE outbox SET status = 'sending', sending_since = ?, last_attempt_at = ? WHERE id = ? AND status = 'waiting'`,
[now, now, params.id],
).changes === 1;
}
@@ -110,11 +112,14 @@ function claim(ctx: StoreContext, params: { id: number }): boolean {
function update(ctx: StoreContext, params: { id: number; status: OutboxStatus; error?: OutboxError; copyId?: string | null }): void {
ctx.db.run(
`UPDATE outbox SET status = ?, error = ?, sending_since = NULL,
first_attempt_at = CASE WHEN ? THEN COALESCE(first_attempt_at, last_attempt_at) ELSE first_attempt_at END,
copy_id = CASE WHEN ? THEN ? ELSE copy_id END
WHERE id = ?`,
[
params.status,
params.error ? JSON.stringify(params.error) : null,
// A try that may have reached the server is what a later lookup has to cover.
params.status === 'uncertain' ? 1 : 0,
params.copyId === undefined ? 0 : 1,
params.copyId ?? null,
params.id,
@@ -125,7 +130,8 @@ function update(ctx: StoreContext, params: { id: number; status: OutboxStatus; e
/** A send a closed tab left behind may have gone out. */
function settleStale(ctx: StoreContext, params: { before: number }): number {
return ctx.db.run(
`UPDATE outbox SET status = 'uncertain', sending_since = NULL WHERE status = 'sending' AND COALESCE(sending_since, 0) < ?`,
`UPDATE outbox SET status = 'uncertain', sending_since = NULL, first_attempt_at = COALESCE(first_attempt_at, last_attempt_at)
WHERE status = 'sending' AND COALESCE(sending_since, 0) < ?`,
[params.before],
).changes;
}
@@ -156,10 +162,18 @@ function heldFile(ctx: StoreContext, params: { blobId: string }): { name: string
return row ? { name: row.name ?? 'attachment', type: row.type ?? 'application/octet-stream', data: row.data } : null;
}
const heldFileSources: Array<(ctx: StoreContext) => Iterable<string>> = [];
/** Another area's records that hold files by their local id, which dropOrphans must leave alone. */
export function addHeldFileSource(source: (ctx: StoreContext) => Iterable<string>): void {
heldFileSources.push(source);
}
/** Files held for no entry (attached offline to a message that was then discarded). SEAM: the drafts agent keeps its drafts' files here too. */
function dropOrphans(ctx: StoreContext, params: { olderThan: number }): number {
return ctx.db.transaction(() => {
const needed = new Set(ctx.db.all<OutboxRow>('SELECT * FROM outbox').flatMap((row) => heldBlobIds(toEntry(row).item)));
for (const source of heldFileSources) for (const id of source(ctx)) needed.add(id);
let dropped = 0;
for (const row of ctx.db.all<{ local_id: string }>('SELECT local_id FROM upload WHERE created_at < ?', [params.olderThan])) {
if (needed.has(String(row.local_id))) continue;
+41 -9
View File
@@ -42,17 +42,26 @@ export type PatchOutcome =
export interface PatchIo {
request(calls: MethodCall[], using: string[]): Promise<JmapResponse>;
/** The server refused the request itself, for good: not an outage, and no use retrying. */
isRefusal(error: unknown): boolean;
}
const BATCH = 200;
/** Method errors that mean "not now" rather than "no". */
const RETRY_LATER = new Set(['serverUnavailable', 'serverFail', 'serverPartialFail', 'rateLimit', 'requestTooLarge']);
function isLater(error: unknown, io: PatchIo): boolean {
/**
* The server refused the request itself, for good: a JMAP problem document with
* a status that says so. `limit` is the parallel-request ceiling, which passes,
* and an error page says something about a proxy, not about this request.
*/
export function isRequestRefusal(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const problem = /^Request failed: (?:400|403|404|413|422)\b[\s\S]*?"type"\s*:\s*"urn:ietf:params:jmap:error:([A-Za-z]+)"/.exec(error.message);
return !!problem && problem[1] !== 'limit';
}
function isLater(error: unknown): boolean {
if (error instanceof JmapMethodError) return RETRY_LATER.has(error.type);
return !io.isRefusal(error);
return !isRequestRefusal(error);
}
function conflict(row: PendingPatch, reason: ConflictReason, server?: EmailObject | null, detail?: string): PatchOutcome {
@@ -67,14 +76,36 @@ function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function flushBatch(io: PatchIo, jmap: string, rows: PendingPatch[]): Promise<PatchOutcome[]> {
async function readRecords(io: PatchIo, jmap: string, rows: PendingPatch[]): Promise<Map<string, EmailObject>> {
const read = await io.request([['Email/get', {
accountId: jmap,
ids: rows.map((row) => row.recordId),
properties: EMAIL_LIST_PROPERTIES,
}, 'g']], MAIL_USING);
const [, got] = responseFor(read, 'g');
const current = new Map((got.list as EmailObject[]).map((email) => [email.id, email]));
return new Map((got.list as EmailObject[]).map((email) => [email.id, email]));
}
/**
* The whole batch was refused. The server's copy of each record goes with the
* conflict so that this device shows what's true again. When even that read is
* refused the record stays as it is here until the next sync.
*/
async function refusedOutcomes(io: PatchIo, jmap: string, rows: PendingPatch[], refusal: unknown): Promise<PatchOutcome[] | null> {
let current: Map<string, EmailObject> | null = null;
try {
current = await readRecords(io, jmap, rows);
} catch (error) {
if (isLater(error)) return null;
}
return rows.map((row) => {
if (current && !current.has(row.recordId)) return row.op === 'destroy' ? done(row) : conflict(row, 'gone', null);
return conflict(row, 'refused', current?.get(row.recordId), errorText(refusal));
});
}
async function flushBatch(io: PatchIo, jmap: string, rows: PendingPatch[]): Promise<PatchOutcome[]> {
const current = await readRecords(io, jmap, rows);
const outcomes: PatchOutcome[] = [];
const update: Record<string, Patch> = {};
@@ -129,12 +160,13 @@ export async function flushPatches(
for (const [jmap, rows] of byAccount) {
for (let i = 0; i < rows.length; i += BATCH) {
const batch = rows.slice(i, i + BATCH);
let outcomes: PatchOutcome[];
let outcomes: PatchOutcome[] | null;
try {
outcomes = await flushBatch(io, jmap, batch);
} catch (error) {
if (isLater(error, io)) return false;
outcomes = batch.map((row) => conflict(row, 'refused', undefined, errorText(error)));
if (isLater(error)) return false;
outcomes = await refusedOutcomes(io, jmap, batch, error);
if (!outcomes) return false;
}
await report(outcomes);
}
+2
View File
@@ -62,7 +62,9 @@ export interface StoredOutboxEntry {
summary: OutboxSummary;
status: OutboxStatus;
sendingSince?: number;
/** When the first try that may have reached the server began. */
firstAttemptAt?: number;
lastAttemptAt?: number;
error?: OutboxError;
item: SendItem;
fingerprint?: PolicyFingerprint;
+6 -4
View File
@@ -4,6 +4,8 @@
* the whole map.
*/
import { keywordKey } from '../mail/records';
export type Patch = Record<string, unknown>;
export interface RecordState {
@@ -103,14 +105,14 @@ export function mergePatches(earlier: Patch, later: Patch): Patch {
}
function sameMap(a: Record<string, boolean> | undefined, b: Record<string, boolean> | undefined): boolean {
const left = Object.keys(a ?? {}).filter((key) => a?.[key]).map((key) => key.toLowerCase()).sort();
const right = Object.keys(b ?? {}).filter((key) => b?.[key]).map((key) => key.toLowerCase()).sort();
const left = Object.keys(a ?? {}).filter((key) => a?.[key]).map(keywordKey).sort();
const right = Object.keys(b ?? {}).filter((key) => b?.[key]).map(keywordKey).sort();
return left.length === right.length && left.every((key, i) => key === right[i]);
}
function has(map: Record<string, boolean> | undefined, key: string): boolean {
const wanted = key.toLowerCase();
return Object.keys(map ?? {}).some((name) => name.toLowerCase() === wanted && map?.[name]);
const wanted = keywordKey(key);
return Object.keys(map ?? {}).some((name) => keywordKey(name) === wanted && map?.[name]);
}
/**
+9
View File
@@ -1,6 +1,15 @@
import type { Migration } from '../storage';
export const OUTBOX_MIGRATIONS: readonly Migration[] = [
{
id: 201,
name: 'outbox_last_attempt',
sql: `
-- When the latest try began. It becomes first_attempt_at only once a try
-- may have reached the server.
ALTER TABLE outbox ADD COLUMN last_attempt_at INTEGER;
`,
},
{
id: 200,
name: 'outbox',
+10 -35
View File
@@ -9,11 +9,14 @@ import { FetchTransport, type StoreSession } from '../engine/transport';
import type { StorageAdapter } from '../storage';
import type { OpenAccountParams, StoreRpc, StoreStatus } from './protocol';
/** Files in the pool that belong to no key. A name that isn't a store file's is left alone. */
export function storeFilesToPrune(files: readonly string[], keep: readonly string[]): string[] {
return files.filter((file) => /^\/a[0-9a-f]+\.db$/.test(file) && !keep.includes(file));
}
export interface HostStorage {
open(file: string, keyHex: string): Promise<StorageAdapter>;
remove(file: string): boolean;
/** Database files in the pool. Their names say nothing about the accounts. */
files(): string[];
}
interface OpenAccount {
@@ -46,8 +49,7 @@ export class StoreHost {
constructor(private readonly options: StoreHostOptions) {}
/** Says whether the account's file was there: gone with a key still around, the browser cleared it. */
async openAccount(params: OpenAccountParams): Promise<{ fileExisted: boolean }> {
async openAccount(params: OpenAccountParams): Promise<{ created: boolean }> {
const existing = this.accounts.get(params.account);
if (existing) {
if (params.session) this.setSession(params.account, params.session);
@@ -55,12 +57,10 @@ export class StoreHost {
if (params.scope) existing.engine.setScope(params.scope);
if (params.invertedKeywordSort !== null) existing.engine.setInvertedKeywordSort(params.invertedKeywordSort);
void existing.engine.requestSync();
return { fileExisted: true };
return { created: false };
}
let fileExisted = this.options.storage.files().includes(params.file);
// Nothing to sync with yet: no empty file, or the next open would take it for the real one.
if (!fileExisted && !params.session) throw new Error('No session for this account yet');
const db = await this.openOrReplace(params, () => { fileExisted = false; });
const db = await this.options.storage.open(params.file, params.keyHex);
const created = !db.get("SELECT 1 AS yes FROM sqlite_master WHERE name = 'schema_migration'");
const session = params.session ?? AccountEngine.storedSession(db);
if (!session) {
db.close();
@@ -91,32 +91,7 @@ export class StoreHost {
account.engine.setOnline(this.online);
this.accounts.set(params.account, account);
void account.engine.requestSync();
return { fileExisted };
}
/** A file this key can't read is lost for good, whatever happened to it, and everything in it can be fetched again. */
private async openOrReplace(params: OpenAccountParams, onReplaced: () => void): Promise<StorageAdapter> {
try {
return await this.options.storage.open(params.file, params.keyHex);
} catch (error) {
if ((error as Error)?.name !== 'StoreKeyError' || !params.session) throw error;
this.options.storage.remove(params.file);
onReplaced();
return this.options.storage.open(params.file, params.keyHex);
}
}
/** Deletes files no account has a key for. Nothing can read them. Files in use stay, whatever the list says. */
sweepFiles(known: readonly string[]): number {
const keep = new Set(known);
for (const open of this.accounts.values()) keep.add(open.file);
let removed = 0;
for (const name of this.options.storage.files()) {
const file = name.replace(/-journal$/, '');
if (keep.has(file)) continue;
if (this.options.storage.remove(file)) removed += 1;
}
return removed;
return { created };
}
setAuth(account: string, auth: string | null): void {
+1 -1
View File
@@ -78,7 +78,7 @@ export type PageToWorker =
| { kind: 'init'; clientId: string; moduleUrl: string }
| { kind: 'open'; id: string; params: OpenAccountParams }
| { kind: 'close-account'; id: string; account: string }
| { kind: 'sweep-files'; id: string; known: string[] }
| { kind: 'prune-files'; id: string; keep: string[] }
| { kind: 'set-auth'; account: string; auth: string | null }
| { kind: 'set-inverted'; account: string; inverted: boolean }
| { kind: 'set-session'; account: string; session: StoreSession }
+6 -5
View File
@@ -9,7 +9,7 @@
import { WasmStorage } from '../adapters/wasm';
import { STORE_AREAS } from '../areas';
import { RPC_CHANNEL, OWNER_LOCK, type ChannelMessage, type PageToWorker, type WorkerToPage } from './protocol';
import { StoreHost } from './host';
import { StoreHost, storeFilesToPrune } from './host';
const scope = self as unknown as {
postMessage(message: WorkerToPage): void;
@@ -115,17 +115,18 @@ scope.addEventListener('message', async (event) => {
replyError(message.id, error);
}
break;
case 'close-account':
case 'prune-files':
try {
await requireHost().closeAccount(message.account);
for (const file of storeFilesToPrune(storage?.files() ?? [], message.keep)) storage?.remove(file);
reply(message.id);
} catch (error) {
replyError(message.id, error);
}
break;
case 'sweep-files':
case 'close-account':
try {
reply(message.id, requireHost().sweepFiles(message.known));
await requireHost().closeAccount(message.account);
reply(message.id);
} catch (error) {
replyError(message.id, error);
}
+12
View File
@@ -1,3 +1,5 @@
import { getDesktopBridge } from '@/lib/desktop/bridge';
/**
* Keeps <meta name="theme-color"> in step with the active theme.
*
@@ -66,6 +68,7 @@ export function syncThemeColorMeta(): void {
const run = () => {
scheduled = false;
writeMeta();
tellDesktopShell();
};
if (typeof requestAnimationFrame === 'function') {
@@ -75,6 +78,15 @@ export function syncThemeColorMeta(): void {
}
}
/** The desktop app's title bar buttons and new windows' first frame follow the page. */
function tellDesktopShell(): void {
const theme = getDesktopBridge()?.theme;
if (!theme) return;
const background = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')?.content;
if (!background) return;
theme.set({ dark: document.documentElement.classList.contains('dark'), background });
}
/** Turn syncing on and take a first reading. Called by <ThemeColorSync />. */
export function enableThemeColorSync(): void {
enabled = true;
+3
View File
@@ -5,6 +5,7 @@
// register endpoint we hit on the relay.
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { isDesktopApp } from '@/lib/desktop/bridge';
import type { AccountInfo, EmailPushConfig, Mailbox } from '@/lib/jmap/types';
import { isJmapSetError, jmapErrorType } from '@/lib/jmap/jmap-error';
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
@@ -443,6 +444,8 @@ export function isWebPushSupported(): boolean {
// No service worker is registered in the static Lite build (see
// components/service-worker-registration.tsx), so push cannot be enabled.
if (IS_LITE) return false;
// Electron has the API, but subscribing always fails: it has no push service.
if (isDesktopApp()) return false;
return (
'serviceWorker' in navigator &&
'PushManager' in window &&
+68
View File
@@ -0,0 +1,68 @@
/**
* Window Controls Overlay: an installed app (and the desktop app's
* titleBarOverlay window) draws the page up to the top of the window and lays
* the native minimise, maximise and close buttons over it.
*
* Asks navigator.windowControlsOverlay, never the media query:
* `(display-mode: window-controls-overlay)` is false in Electron with
* titleBarOverlay, while the API and env(titlebar-area-*) work there.
*
* app/globals.css gives --titlebar-inset its first value from
* env(titlebar-area-height, 0px), so the page has the right top padding from
* the first paint, before any script runs. This module keeps the variable in
* step with the API afterwards, which is what a browser or bridge without the
* env() values relies on.
*/
export interface TitlebarArea {
x: number;
y: number;
width: number;
height: number;
}
interface OverlayLike extends EventTarget {
visible: boolean;
getTitlebarAreaRect?: () => { x: number; y: number; width: number; height: number };
}
export const TITLEBAR_INSET_VAR = '--titlebar-inset';
function overlay(): OverlayLike | null {
if (typeof navigator === 'undefined') return null;
const value = (navigator as Navigator & { windowControlsOverlay?: OverlayLike }).windowControlsOverlay;
return value ?? null;
}
/** The strip the page may draw in next to the window's controls, or null when there is no overlay. */
export function readTitlebarArea(): TitlebarArea | null {
const current = overlay();
if (!current?.visible) return null;
const rect = current.getTitlebarAreaRect?.();
if (!rect || !(rect.height > 0)) return null;
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}
/**
* Sets --titlebar-inset for this area. With no area the inline value goes, so
* the stylesheet's value (0 without an overlay) is what counts again.
*/
export function applyTitlebarInset(area: TitlebarArea | null, root: HTMLElement = document.documentElement): void {
if (area) root.style.setProperty(TITLEBAR_INSET_VAR, `${area.height}px`);
else root.style.removeProperty(TITLEBAR_INSET_VAR);
}
/**
* Calls `listener` now and whenever the overlay's size or visibility changes
* (`geometrychange`). Without the API it calls it once with null and listens
* to nothing, so a browser without it runs no extra code. Returns the
* unsubscribe.
*/
export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void): () => void {
listener(readTitlebarArea());
const current = overlay();
if (!current) return () => {};
const onChange = () => listener(readTitlebarArea());
current.addEventListener('geometrychange', onChange);
return () => current.removeEventListener('geometrychange', onChange);
}