mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)]');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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', '');`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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: {} }));
|
||||
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' }] });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user