mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
Merge branch 'feat/desktop-offline' into feat/do-remote-wipe
# Conflicts: # components/offline/offline-settings.tsx # locales/ar/common.json # locales/ca/common.json # locales/cs/common.json # locales/da/common.json # locales/de/common.json # locales/en/common.json # locales/es/common.json # locales/et/common.json # locales/fa/common.json # locales/fr/common.json # locales/he/common.json # locales/hu/common.json # locales/it/common.json # locales/ja/common.json # locales/ko/common.json # locales/lv/common.json # locales/mn/common.json # locales/nb/common.json # locales/nl/common.json # locales/pl/common.json # locales/pt/common.json # locales/ro/common.json # locales/ru/common.json # locales/sk/common.json # locales/th/common.json # locales/tr/common.json # locales/uk/common.json # locales/vi/common.json # locales/zh-TW/common.json # locales/zh/common.json
This commit is contained in:
@@ -70,6 +70,31 @@ describe('Offline mode section', () => {
|
||||
it('says what a device keeps and links the docs', async () => {
|
||||
await renderSection();
|
||||
expect(screen.getByRole('link', { name: 'How offline mode works' }).getAttribute('href')).toBe('https://bulwarkmail.org/docs/guides/organisation/offline-mode');
|
||||
expect(document.body.textContent).toContain('Bulwark Lite has no offline mode');
|
||||
expect(document.body.textContent).toContain('Bulwark Lite reads the same policy but has no offline mode');
|
||||
expect(document.body.textContent).not.toContain('250 MB');
|
||||
});
|
||||
|
||||
it('saves the caps as numbers and takes a cleared field as no cap', async () => {
|
||||
await renderSection();
|
||||
const days = screen.getByTestId('org-devices-offlineMaxDays') as HTMLInputElement;
|
||||
fireEvent.change(days, { target: { value: '45' } });
|
||||
fireEvent.change(screen.getByTestId('org-devices-offlineMaxAttachmentMb'), { target: { value: '10' } });
|
||||
fireEvent.change(screen.getByTestId('org-devices-offlineMaxTotalMb'), { target: { value: '200' } });
|
||||
fireEvent.click(screen.getByTestId('org-devices-noOfflineAttachments'));
|
||||
await act(async () => { await latest!.save(); });
|
||||
expect((puts[0].organisation as { devices: unknown }).devices).toMatchObject({
|
||||
offlineMaxDays: 45, offlineMaxAttachmentMb: 10, offlineMaxTotalMb: 200, noOfflineAttachments: true,
|
||||
});
|
||||
fireEvent.change(days, { target: { value: '' } });
|
||||
expect(latest!.policy?.organisation?.devices.offlineMaxDays).toBe(0);
|
||||
expect(days.value).toBe('');
|
||||
});
|
||||
|
||||
it('writes a cap into the scope only', async () => {
|
||||
await renderSection();
|
||||
act(() => latest!.setScopeId('field'));
|
||||
fireEvent.change(screen.getByTestId('org-devices-offlineMaxDays'), { target: { value: '30' } });
|
||||
expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ devices: { offlineMaxDays: 30 } });
|
||||
expect(latest!.policy?.organisation?.devices.offlineMaxDays).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,34 +6,113 @@ import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/offline-mode';
|
||||
|
||||
interface CapField {
|
||||
key: 'offlineMaxDays' | 'offlineMaxAttachmentMb' | 'offlineMaxTotalMb';
|
||||
label: string;
|
||||
description: string;
|
||||
unit: string;
|
||||
max: number;
|
||||
}
|
||||
|
||||
const CAP_FIELDS: CapField[] = [
|
||||
{
|
||||
key: 'offlineMaxDays',
|
||||
label: 'Keep mail for at most',
|
||||
description: 'Mail older than this leaves the device at its next policy fetch, and calendar events that ended before it too. People can still choose less.',
|
||||
unit: 'days',
|
||||
max: 3650,
|
||||
},
|
||||
{
|
||||
key: 'offlineMaxAttachmentMb',
|
||||
label: 'Largest attachment or file kept',
|
||||
description: 'Anything bigger leaves the device and opens from the server when there is a connection. This also holds for files someone chose to keep offline.',
|
||||
unit: 'MB',
|
||||
max: 1024 * 1024,
|
||||
},
|
||||
{
|
||||
key: 'offlineMaxTotalMb',
|
||||
label: 'All attachments and files, per account',
|
||||
description: 'The oldest go first when a device is over this. Message text isn’t counted: limit that with the days above.',
|
||||
unit: 'MB',
|
||||
max: 1024 * 1024,
|
||||
},
|
||||
];
|
||||
|
||||
const describeCap = (value: number, unit: string) => (value > 0 ? `${value} ${unit}` : 'no limit');
|
||||
|
||||
/**
|
||||
* Organisation > Devices > Offline mode (#523): whether people may keep
|
||||
* mail on their devices (policy.json `organisation.devices.noOffline`).
|
||||
* Users turn offline mode on per device; this switch takes it away, and a
|
||||
* device that already keeps mail deletes it at its next policy fetch.
|
||||
* Organisation > Devices > Offline mode (#523): whether people may keep mail
|
||||
* on their devices, and how much (policy.json `organisation.devices`). These
|
||||
* win over what a user picks and over the desktop app's default. Nothing on
|
||||
* the server can check them, since the mail is on the device: the app holds
|
||||
* itself to them, which the text says.
|
||||
*/
|
||||
export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
const { value, own, everyone } = draft.orgValue('devices', 'noOffline');
|
||||
const forbid = draft.orgValue('devices', 'noOffline');
|
||||
const noAttachments = draft.orgValue('devices', 'noOfflineAttachments');
|
||||
return (
|
||||
<SettingsSection
|
||||
id="offline"
|
||||
title="Offline mode"
|
||||
description="People can turn on “Keep mail on this device” in Settings > Backup & reset to read and write mail without a connection. It is off on every device until someone turns it on there."
|
||||
description="People can keep mail on a device to read and write it without a connection. In browsers it is off until someone turns on “Keep mail on this device” in Settings > Backup & reset. In the Bulwark desktop app it is on unless you forbid it here."
|
||||
>
|
||||
<SettingItem
|
||||
label="Don't let people keep mail on their devices"
|
||||
description="Hides the switch. A device that already keeps mail deletes it, unsent messages in its outbox included, the next time it fetches the policy (when the tab is opened or focused, and every 15 minutes)."
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<ToggleSwitch checked={value} onChange={(v) => draft.setOrgValue('devices', 'noOffline', v)} testId="org-devices-noOffline" />
|
||||
<InheritNote draft={draft} own={own} everyone={everyone ? 'on' : 'off'} onReset={() => draft.clearOrgValue('devices', 'noOffline')} />
|
||||
<ToggleSwitch checked={forbid.value} onChange={(v) => draft.setOrgValue('devices', 'noOffline', v)} testId="org-devices-noOffline" />
|
||||
<InheritNote draft={draft} own={forbid.own} everyone={forbid.everyone ? 'on' : 'off'} onReset={() => draft.clearOrgValue('devices', 'noOffline')} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
{CAP_FIELDS.map((field) => {
|
||||
const { value, own, everyone } = draft.orgValue('devices', field.key);
|
||||
return (
|
||||
<SettingItem key={field.key} label={field.label} description={field.description}>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={field.max}
|
||||
step={1}
|
||||
data-testid={`org-devices-${field.key}`}
|
||||
aria-label={`${field.label} (${field.unit})`}
|
||||
placeholder="No limit"
|
||||
value={value > 0 ? value : ''}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim();
|
||||
const parsed = raw === '' ? 0 : Math.round(Number(raw));
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
draft.setOrgValue('devices', field.key, Math.min(field.max, Math.max(0, parsed)));
|
||||
}}
|
||||
className="h-9 w-28 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{field.unit}</span>
|
||||
</div>
|
||||
<InheritNote draft={draft} own={own} everyone={describeCap(everyone, field.unit)} onReset={() => draft.clearOrgValue('devices', field.key)} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
);
|
||||
})}
|
||||
<SettingItem
|
||||
label="Don't keep attachments or files"
|
||||
description="Mail stays on the device without its attachments, and files kept for offline use are removed. They open from the server when there is a connection."
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<ToggleSwitch checked={noAttachments.value} onChange={(v) => draft.setOrgValue('devices', 'noOfflineAttachments', v)} testId="org-devices-noOfflineAttachments" />
|
||||
<InheritNote draft={draft} own={noAttachments.own} everyone={noAttachments.everyone ? 'on' : 'off'} onReset={() => draft.clearOrgValue('devices', 'noOfflineAttachments')} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
<p className="pt-2 text-xs text-muted-foreground">
|
||||
What a device keeps: the last 30 days of the Inbox and of the folders the person opens, at most 250 MB, encrypted in
|
||||
the browser. That protects against someone copying the browser's files, not against someone using the unlocked
|
||||
browser. Every sign-out, the automatic one after inactivity included, deletes it. With several accounts signed in,
|
||||
each account follows its own setting. Bulwark Lite has no offline mode.{' '}
|
||||
What a device keeps: mail in every folder from the window the person picks (30 days, 90 days, a year or everything),
|
||||
with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not
|
||||
against someone using the unlocked browser or app. The limits above can only lower what people pick: where several
|
||||
scopes apply, the lowest value counts, so a scope can't raise what another one lowered. The app holds itself to
|
||||
them when it fetches the policy; the server can't check what is on a device. Every sign-out, the automatic one
|
||||
after inactivity included, deletes everything. With several accounts signed in, each account follows its own settings.
|
||||
Bulwark Lite reads the same policy but has no offline mode.{' '}
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-2">
|
||||
How offline mode works
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// A crypto plugin's render can be a key operation: with a smart card a dialog, and two
|
||||
// PIN cancels in a row lock the key. So the render hook runs once per message, not once
|
||||
// per object: mark-read replaces the object and changes nothing the hook gets to see.
|
||||
// And a plugin can keep external content off for a body it can't vouch for (EFAIL).
|
||||
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => true,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { renderHooks, clearAllHooks } = await import('@/lib/plugin-hooks');
|
||||
const { usePluginRenderedBody, pluginRenderKey } = await import('@/hooks/use-plugin-rendered-body');
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
const { ConversationEmailBody } = await import('../thread-conversation-view');
|
||||
|
||||
const REMOTE = 'https://tracker.example/x.png';
|
||||
// The blocker keeps the address in an inert attribute, for "Load images". What matters is that it isn't a live one.
|
||||
const LOADS_REMOTE = /<img[^>]*\ssrc="https:\/\/tracker/;
|
||||
const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
|
||||
|
||||
const message = (over: Partial<Email> = {}) => ({
|
||||
id: 'e1',
|
||||
blobId: 'b1',
|
||||
threadId: 't1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: {},
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
to: [{ name: 'Bob', email: 'bob@example.com' }],
|
||||
subject: 'Hello',
|
||||
receivedAt: '2026-01-01T00:00:00Z',
|
||||
preview: 'Hello',
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
textBody: [{ partId: '1', type: 'text/plain' }],
|
||||
htmlBody: [],
|
||||
attachments: [],
|
||||
bodyStructure: { type: 'application/pkcs7-mime' },
|
||||
headers: { 'content-type': 'application/pkcs7-mime; smime-type=enveloped-data' },
|
||||
bodyValues: { '1': { value: 'Hello Bob', isTruncated: false } },
|
||||
...over,
|
||||
}) as unknown as Email;
|
||||
|
||||
const renders = () => {
|
||||
const handler = vi.fn(async () => ({ html: '', text: 'Decrypted', attachments: [], handledBy: 'smime' }));
|
||||
renderHooks.onRenderEmailBody.register('smime', handler as never);
|
||||
return handler;
|
||||
};
|
||||
|
||||
const openViewer = (email: Email) => <EmailViewer email={email} mailboxes={mailboxes} />;
|
||||
|
||||
const originalDelay = useSettingsStore.getState().markAsReadDelay;
|
||||
const originalPolicy = useSettingsStore.getState().externalContentPolicy;
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ markAsReadDelay: -1 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearAllHooks();
|
||||
useSettingsStore.setState({ markAsReadDelay: originalDelay, externalContentPolicy: originalPolicy });
|
||||
});
|
||||
|
||||
describe('pluginRenderKey', () => {
|
||||
it('ignores what the hook does not get: keywords and mailboxes', () => {
|
||||
expect(pluginRenderKey(message({ keywords: { $seen: true } }))).toBe(pluginRenderKey(message()));
|
||||
expect(pluginRenderKey(message({ mailboxIds: { archive: true } }))).toBe(pluginRenderKey(message()));
|
||||
});
|
||||
|
||||
it('changes with the message and its blob', () => {
|
||||
expect(pluginRenderKey(message({ id: 'e2' }))).not.toBe(pluginRenderKey(message()));
|
||||
expect(pluginRenderKey(message({ blobId: 'b2' }))).not.toBe(pluginRenderKey(message()));
|
||||
});
|
||||
|
||||
it('changes when the list row becomes the full message, which is what the hook decides on', () => {
|
||||
const row = message({ headers: undefined, bodyStructure: undefined, bodyValues: undefined });
|
||||
expect(pluginRenderKey(message())).not.toBe(pluginRenderKey(row));
|
||||
});
|
||||
|
||||
it('changes when a truncated body arrives whole', () => {
|
||||
const whole = message({ bodyValues: { '1': { value: 'Hello Bob, and the rest', isTruncated: false } } as never });
|
||||
expect(pluginRenderKey(whole)).not.toBe(pluginRenderKey(message()));
|
||||
});
|
||||
});
|
||||
|
||||
describe('usePluginRenderedBody', () => {
|
||||
it('runs the hook once when mark-read replaces the message object', async () => {
|
||||
const handler = renders();
|
||||
const { result, rerender } = renderHook(({ email }) => usePluginRenderedBody(email), { initialProps: { email: message() } });
|
||||
await waitFor(() => expect(result.current.text).toBe('Decrypted'));
|
||||
rerender({ email: message({ keywords: { $seen: true } }) });
|
||||
await act(async () => {});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.text).toBe('Decrypted');
|
||||
});
|
||||
|
||||
it('runs it again for an asked-for render, and for another message', async () => {
|
||||
const handler = renders();
|
||||
const { rerender } = renderHook(({ email }) => usePluginRenderedBody(email), { initialProps: { email: message() } });
|
||||
await waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
|
||||
await act(async () => { window.dispatchEvent(new CustomEvent('plugin:rerender-email')); });
|
||||
await waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
|
||||
rerender({ email: message({ id: 'e2', blobId: 'b2' }) });
|
||||
await waitFor(() => expect(handler).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
|
||||
it('says when the plugin wants external content off', async () => {
|
||||
renderHooks.onRenderEmailBody.register('smime', (async () => ({
|
||||
html: '<p>Body</p>', text: '', attachments: [], handledBy: 'smime', blockExternalContent: true,
|
||||
})) as never);
|
||||
const { result } = renderHook(() => usePluginRenderedBody(message()));
|
||||
await waitFor(() => expect(result.current.blockExternal).toBe(true));
|
||||
});
|
||||
|
||||
it('leaves it to the settings otherwise', async () => {
|
||||
const handler = renders();
|
||||
const { result } = renderHook(() => usePluginRenderedBody(message()));
|
||||
await waitFor(() => expect(handler).toHaveBeenCalled());
|
||||
await waitFor(() => expect(result.current.text).toBe('Decrypted'));
|
||||
expect(result.current.blockExternal).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailViewer', () => {
|
||||
it('runs the hook once when mark-read replaces the message object', async () => {
|
||||
const handler = renders();
|
||||
const { rerender } = render(openViewer(message()));
|
||||
await screen.findByText('Decrypted');
|
||||
rerender(openViewer(message({ keywords: { $seen: true } })));
|
||||
await act(async () => {});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('Decrypted')).toBeTruthy();
|
||||
}, 30_000);
|
||||
|
||||
it('runs it again when the list row becomes the full message', async () => {
|
||||
const handler = renders();
|
||||
const { rerender } = render(openViewer(message({ headers: undefined, bodyStructure: undefined })));
|
||||
await waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
|
||||
rerender(openViewer(message()));
|
||||
await waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
|
||||
}, 30_000);
|
||||
|
||||
it('runs it again for an asked-for render', async () => {
|
||||
const handler = renders();
|
||||
render(openViewer(message()));
|
||||
await waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
|
||||
await act(async () => { window.dispatchEvent(new CustomEvent('plugin:rerender-email')); });
|
||||
await waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
|
||||
}, 30_000);
|
||||
|
||||
const remoteBody = (blockExternalContent?: boolean) => {
|
||||
renderHooks.onRenderEmailBody.register('smime', (async () => ({
|
||||
html: `<p>Secret body</p><img src="${REMOTE}">`, text: '', attachments: [], handledBy: 'smime',
|
||||
...(blockExternalContent ? { blockExternalContent } : {}),
|
||||
})) as never);
|
||||
};
|
||||
const frameDoc = (container: HTMLElement) => container.querySelector('iframe')?.getAttribute('srcdoc') ?? '';
|
||||
|
||||
it('keeps external content off when the plugin says so, whatever the settings allow', async () => {
|
||||
useSettingsStore.setState({ externalContentPolicy: 'allow' });
|
||||
remoteBody(true);
|
||||
const { container } = render(openViewer(message()));
|
||||
await waitFor(() => expect(frameDoc(container)).toContain('Secret body'));
|
||||
expect(frameDoc(container)).not.toMatch(LOADS_REMOTE);
|
||||
}, 30_000);
|
||||
|
||||
it('offers no way to load it then', async () => {
|
||||
useSettingsStore.setState({ externalContentPolicy: 'ask' });
|
||||
remoteBody(true);
|
||||
const { container } = render(openViewer(message()));
|
||||
await waitFor(() => expect(frameDoc(container)).toContain('Secret body'));
|
||||
await act(async () => {});
|
||||
expect(screen.queryByTestId('external-content-notice')).toBeNull();
|
||||
}, 30_000);
|
||||
|
||||
it('follows the settings for any other plugin body', async () => {
|
||||
useSettingsStore.setState({ externalContentPolicy: 'allow' });
|
||||
remoteBody();
|
||||
const { container } = render(openViewer(message()));
|
||||
await waitFor(() => expect(frameDoc(container)).toContain('Secret body'));
|
||||
expect(frameDoc(container)).toMatch(LOADS_REMOTE);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('ConversationEmailBody', () => {
|
||||
const body = (allowExternal: boolean, onAllowExternal = () => {}) => (
|
||||
<ConversationEmailBody email={message()} allowExternal={allowExternal} onAllowExternal={onAllowExternal} />
|
||||
);
|
||||
|
||||
it('keeps external content off when the plugin says so, even with it allowed', async () => {
|
||||
renderHooks.onRenderEmailBody.register('smime', (async () => ({
|
||||
html: `<p>Secret body</p><img src="${REMOTE}">`, text: '', attachments: [], handledBy: 'smime', blockExternalContent: true,
|
||||
})) as never);
|
||||
const { container } = render(body(true));
|
||||
await waitFor(() => expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').toContain('Secret body'));
|
||||
expect(container.querySelector('iframe')?.getAttribute('srcdoc')).not.toMatch(LOADS_REMOTE);
|
||||
}, 30_000);
|
||||
|
||||
it('has no button to load it', async () => {
|
||||
renderHooks.onRenderEmailBody.register('smime', (async () => ({
|
||||
html: `<p>Secret body</p><img src="${REMOTE}">`, text: '', attachments: [], handledBy: 'smime', blockExternalContent: true,
|
||||
})) as never);
|
||||
const { container } = render(body(false));
|
||||
await waitFor(() => expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').toContain('Secret body'));
|
||||
await act(async () => {});
|
||||
expect(screen.queryByRole('button', { name: /load_external_content|load/i })).toBeNull();
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -127,6 +127,7 @@ import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
|
||||
import { pluginRenderKey } from "@/hooks/use-plugin-rendered-body";
|
||||
import { registerComposeFlush } from "@/lib/compose-flush-registry";
|
||||
import { useExternalRecipientConfirmStore } from "@/stores/external-recipient-confirm-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
@@ -1784,6 +1785,9 @@ export function EmailViewer({
|
||||
const [pluginRenderedHtml, setPluginRenderedHtml] = useState<string | null>(null);
|
||||
const [pluginRenderedText, setPluginRenderedText] = useState<string | null>(null);
|
||||
const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState<PostalMimeAttachment[]>(NO_POSTAL_ATTACHMENTS);
|
||||
// The plugin can't vouch for its body (mail with no integrity protection): external
|
||||
// content stays off, whatever the settings or a trusted sender allow.
|
||||
const [pluginBlocksExternal, setPluginBlocksExternal] = useState(false);
|
||||
// Bumped when a plugin calls `api.ui.rerenderEmail` (e.g. the S/MIME plugin
|
||||
// after the user unlocks a key from the banner) to force the onRenderEmailBody
|
||||
// hook to run again for the open message so the body re-decrypts.
|
||||
@@ -2256,6 +2260,7 @@ export function EmailViewer({
|
||||
setPluginRenderedHtml(null);
|
||||
setPluginRenderedText(null);
|
||||
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
|
||||
setPluginBlocksExternal(false);
|
||||
setTnefHtml(null);
|
||||
setTnefText(null);
|
||||
setTnefAttachments(NO_TNEF_ATTACHMENTS);
|
||||
@@ -2277,6 +2282,7 @@ export function EmailViewer({
|
||||
setPluginRenderedHtml(null);
|
||||
setPluginRenderedText(null);
|
||||
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
|
||||
setPluginBlocksExternal(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -2318,14 +2324,17 @@ export function EmailViewer({
|
||||
text?: string;
|
||||
attachments?: Array<{ name?: string; type?: string; size?: number; dataUrl?: string; cid?: string }>;
|
||||
handledBy?: string;
|
||||
blockExternalContent?: boolean;
|
||||
};
|
||||
if (cancelled) return;
|
||||
if (!result || !result.handledBy) {
|
||||
setPluginRenderedHtml(null);
|
||||
setPluginRenderedText(null);
|
||||
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
|
||||
setPluginBlocksExternal(false);
|
||||
return;
|
||||
}
|
||||
setPluginBlocksExternal(result.blockExternalContent === true);
|
||||
setPluginRenderedHtml(typeof result.html === 'string' && result.html ? result.html : null);
|
||||
setPluginRenderedText(typeof result.text === 'string' && result.text ? result.text : null);
|
||||
// Normalise the plugin's attachment shape into the PostalMime-like shape
|
||||
@@ -2344,11 +2353,15 @@ export function EmailViewer({
|
||||
setPluginRenderedHtml(null);
|
||||
setPluginRenderedText(null);
|
||||
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
|
||||
setPluginBlocksExternal(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [email, pluginRenderNonce]);
|
||||
// Once per message, not per object: a render can be a key operation, and mark-read
|
||||
// replaces the object. pluginRenderKey says what should run this again.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pluginRenderKey(email), pluginRenderNonce]);
|
||||
|
||||
// TNEF (winmail.dat) detection and processing
|
||||
useEffect(() => {
|
||||
@@ -2694,7 +2707,7 @@ export function EmailViewer({
|
||||
// 'ask' = block until the user allows this message or trusts the sender.
|
||||
const shouldBlockExternal = useMemo(() => {
|
||||
if (!email) return false;
|
||||
if (remoteHeldBack) return true;
|
||||
if (remoteHeldBack || pluginBlocksExternal) return true;
|
||||
const senderEmail = email.from?.[0]?.email?.toLowerCase();
|
||||
const senderIsTrusted = senderEmail
|
||||
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
|
||||
@@ -2707,7 +2720,7 @@ export function EmailViewer({
|
||||
// permission changes, so they're deliberately omitted from deps (matches
|
||||
// the srcDoc rebuild contract described on `emailContent`).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email, externalContentPolicy, allowExternalContent, remoteHeldBack]);
|
||||
}, [email, externalContentPolicy, allowExternalContent, remoteHeldBack, pluginBlocksExternal]);
|
||||
|
||||
// Reported as phishing ($phishing): a banner, its links turned off and
|
||||
// no cards (a phishing mail's "booking" is the lure).
|
||||
@@ -5674,7 +5687,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
|
||||
{/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
|
||||
{(senderVerification ||
|
||||
(!plainView && hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow'))) ||
|
||||
(!plainView && !pluginBlocksExternal && hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow'))) ||
|
||||
hasCalendarInvitation ||
|
||||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt) ||
|
||||
structuredCards.length > 0) && (
|
||||
@@ -5707,7 +5720,8 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
{/* External Content Controls */}
|
||||
{/* shouldBlockExternal: gone once trust applies (e.g. the
|
||||
policy finished loading after the first paint). */}
|
||||
{!plainView && hasBlockedContent && shouldBlockExternal && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow')) && (
|
||||
{/* A plugin's own banner says why it keeps external content off, and there's nothing to load. */}
|
||||
{!plainView && !pluginBlocksExternal && hasBlockedContent && shouldBlockExternal && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow')) && (
|
||||
<div className="flex items-start gap-3 py-1" data-testid="external-content-notice">
|
||||
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<Image className="w-5 h-5" />
|
||||
|
||||
@@ -549,7 +549,7 @@ interface ConversationEmailBodyProps {
|
||||
*/
|
||||
export function ConversationEmailBody({
|
||||
email,
|
||||
allowExternal,
|
||||
allowExternal: externalAllowed,
|
||||
onAllowExternal,
|
||||
onTrustSender,
|
||||
onDownloadAttachment,
|
||||
@@ -602,6 +602,8 @@ export function ConversationEmailBody({
|
||||
const cidBlobUrls = useMemo(() => cidResult?.request === cidRequest ? cidResult.urls : {}, [cidResult, cidRequest]);
|
||||
// A crypto plugin's decrypted body (PGP, S/MIME) replaces the message's own.
|
||||
const pluginBody = usePluginRenderedBody(email);
|
||||
// The plugin can't vouch for its body: external content stays off, whatever was allowed.
|
||||
const allowExternal = externalAllowed && !pluginBody.blockExternal;
|
||||
// reader-above-quote: the body the quote was collapsed in, once it is on screen.
|
||||
const [htmlBodyEl, setHtmlBodyEl] = useState<HTMLElement | null>(null);
|
||||
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
|
||||
@@ -930,7 +932,7 @@ export function ConversationEmailBody({
|
||||
<div className={className}>
|
||||
{phishingMarked && <PhishingMarkBanner className="px-4" />}
|
||||
{/* External content warning */}
|
||||
{hasBlockedContent && !allowExternal && emailContent.isHtml && (
|
||||
{hasBlockedContent && !allowExternal && !pluginBody.blockExternal && emailContent.isHtml && (
|
||||
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm" data-testid="external-content-notice">
|
||||
<span className="text-muted-foreground">
|
||||
{remoteBlockedByOrg ? t("third_party.remote_content_blocked") : t("email_viewer.external_content_warning")}
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info,
|
||||
Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info, Pin, PinOff,
|
||||
FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen,
|
||||
RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput,
|
||||
} from "@/components/icons";
|
||||
import { useIsDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useKeepFileOffline } from "@/hooks/use-keep-file-offline";
|
||||
import { useRegisterCommands } from "@/lib/command-registry";
|
||||
import { formatShortcutCombo } from "@/lib/shortcut-keys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -319,6 +320,8 @@ export function FileBrowser({
|
||||
const dragDepth = useRef(0);
|
||||
const anchorName = useRef<string | null>(null);
|
||||
const menu = useContextMenu<MenuData>();
|
||||
const tOffline = useTranslations("offlineStorage");
|
||||
const { keptOffline, canKeepOffline, toggleKeptOffline } = useKeepFileOffline(client);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -939,6 +942,13 @@ export function FileBrowser({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canKeepOffline(resource) && (
|
||||
<ContextMenuItem
|
||||
icon={keptOffline.has(resource.id) ? PinOff : Pin}
|
||||
label={keptOffline.has(resource.id) ? tOffline("unpin") : tOffline("keep_offline")}
|
||||
onClick={() => { closeMenu(); void toggleKeptOffline(resource); }}
|
||||
/>
|
||||
)}
|
||||
{(canShare(resource) || (resource.isDirectory && !resource.isShared)) && <ContextMenuSeparator />}
|
||||
{canShare(resource) && (
|
||||
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => { closeMenu(); setShareTargetId(resource.id); }} />
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const offline = vi.hoisted(() => ({
|
||||
clearAccount: vi.fn(async () => {}),
|
||||
disableOffline: vi.fn(async () => {}),
|
||||
unsentCount: vi.fn(async () => ({ messages: 0, changes: 0 })),
|
||||
chooseScope: vi.fn(),
|
||||
requestPersistence: vi.fn(async () => 'refused'),
|
||||
dismissAreaConflict: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock('@/lib/offline/offline-mode', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/offline/offline-mode')>()),
|
||||
clearAccount: offline.clearAccount,
|
||||
disableOffline: offline.disableOffline,
|
||||
unsentCount: offline.unsentCount,
|
||||
chooseScope: offline.chooseScope,
|
||||
requestPersistence: offline.requestPersistence,
|
||||
dismissAreaConflict: offline.dismissAreaConflict,
|
||||
readPersistence: vi.fn(async () => 'refused'),
|
||||
refreshUsage: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
import { OfflineSettings } from '../offline-settings';
|
||||
import { OfflineBanner } from '../offline-banner';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api';
|
||||
import { fileRefFor } from '@/hooks/use-keep-file-offline';
|
||||
|
||||
const area = vi.fn(async (_account: string, name: string) => (name === 'files.kept' ? {
|
||||
files: [
|
||||
{ jmap: 'j', id: 'f1', name: 'Pinned.pdf', size: 2048, pinned: true, kept: true, error: null },
|
||||
{ jmap: 'j', id: 'f2', name: 'Big.iso', size: 1048576, pinned: true, kept: false, error: 'overLimit' },
|
||||
{ jmap: 'j', id: 'f3', name: 'Opened.txt', size: 5, pinned: false, kept: true, error: null },
|
||||
],
|
||||
} : null));
|
||||
|
||||
function setDevices(devices: Partial<typeof DEFAULT_ORGANISATION_POLICY.devices>) {
|
||||
usePolicyStore.setState({
|
||||
loaded: true, source: 'server', subject: { email: 'alice@example.org', groups: [] },
|
||||
policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } },
|
||||
} as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
setStoreBackend({ supported: () => true, area, setScope: vi.fn(async () => {}) } as unknown as StoreBackend);
|
||||
useAuthStore.setState({ activeAccountId: 'acc', getAllConnectedClients: (() => new Map()) as never });
|
||||
useOfflineStore.setState({
|
||||
enabled: ['acc'], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], syncing: [], lastSyncedAt: {}, choices: { acc: { on: true } }, health: {},
|
||||
storageFull: {}, progress: {}, persist: 'granted', browserOffline: false, downServers: [], startedOffline: false,
|
||||
usage: { acc: { bytes: 5000, parts: [{ part: 'mail', bytes: 4000, items: 12 }, { part: 'attachments', bytes: 1000, items: 2 }] } },
|
||||
});
|
||||
setDevices({});
|
||||
});
|
||||
|
||||
afterEach(() => { setStoreBackend(null); });
|
||||
|
||||
const windowSelect = () => screen.getByText('window_label').closest('[data-search-label]')!.querySelector('select') as HTMLSelectElement;
|
||||
|
||||
describe('what an account keeps', () => {
|
||||
it('offers every window and hands the choice on', () => {
|
||||
render(<OfflineSettings />);
|
||||
expect([...windowSelect().options].map((o) => o.value)).toEqual(['30', '90', '365', 'all']);
|
||||
expect(windowSelect().value).toBe('90');
|
||||
fireEvent.change(windowSelect(), { target: { value: 'all' } });
|
||||
expect(offline.chooseScope).toHaveBeenCalledWith('acc', { days: null });
|
||||
});
|
||||
|
||||
it('under a cap offers only what fits, and says who set it', () => {
|
||||
setDevices({ offlineMaxDays: 60 });
|
||||
render(<OfflineSettings />);
|
||||
expect([...windowSelect().options].map((o) => o.value)).toEqual(['30', '60']);
|
||||
expect(windowSelect().value).toBe('60');
|
||||
expect(within(screen.getByText('window_label').closest('[data-search-label]') as HTMLElement).getByText('org_locked')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('locks the window when the cap leaves no choice', () => {
|
||||
setDevices({ offlineMaxDays: 14 });
|
||||
render(<OfflineSettings />);
|
||||
const row = screen.getByText('window_label').closest('[data-search-label]') as HTMLElement;
|
||||
expect(windowSelect().parentElement?.closest('[inert]')).not.toBeNull();
|
||||
expect(within(row).getByText('org_locked')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('locks attachments off when the organisation forbids them, and hides the size', () => {
|
||||
useOfflineStore.setState({ choices: { acc: { on: true, attachments: true } } });
|
||||
setDevices({ noOfflineAttachments: true });
|
||||
render(<OfflineSettings />);
|
||||
const toggle = screen.getByTestId('offline-attachments');
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false');
|
||||
expect(toggle.closest('[inert]')).not.toBeNull();
|
||||
expect(screen.queryByText('attachment_cap_label')).toBeNull();
|
||||
fireEvent.click(toggle);
|
||||
});
|
||||
|
||||
it('caps the size choice', () => {
|
||||
setDevices({ offlineMaxAttachmentMb: 10 });
|
||||
render(<OfflineSettings />);
|
||||
const select = screen.getByText('attachment_cap_label').closest('[data-search-label]')!.querySelector('select') as HTMLSelectElement;
|
||||
expect([...select.options].map((o) => o.value)).toEqual(['5', '10']);
|
||||
fireEvent.change(select, { target: { value: '5' } });
|
||||
expect(offline.chooseScope).toHaveBeenCalledWith('acc', { maxAttachmentBytes: 5 * 1024 * 1024 });
|
||||
});
|
||||
|
||||
it('says when the total is set by the organisation', () => {
|
||||
setDevices({ offlineMaxTotalMb: 100 });
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.getByTestId('offline-total-locked').textContent).toContain('total_locked');
|
||||
});
|
||||
|
||||
it('locks the switch when the desktop policy forces it', () => {
|
||||
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, policy: { get: () => ({ OfflineEnabled: { value: true, locked: true, source: 'machine' } }) } };
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.getByTestId('offline-switch').closest('[inert]')).not.toBeNull();
|
||||
delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
});
|
||||
|
||||
it('shows nothing of this with offline off', () => {
|
||||
useOfflineStore.setState({ enabled: [] });
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.queryByText('window_label')).toBeNull();
|
||||
expect(screen.queryByTestId('offline-clear')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what it takes', () => {
|
||||
it('lists each part', () => {
|
||||
render(<OfflineSettings />);
|
||||
const usage = screen.getByTestId('offline-usage');
|
||||
expect(usage.textContent).toContain('usage_mail');
|
||||
expect(usage.textContent).toContain('usage_attachments');
|
||||
expect(screen.getByTestId('offline-status').textContent).toContain('usage');
|
||||
});
|
||||
|
||||
it('shows the time of the last sync from before a reload', () => {
|
||||
useOfflineStore.setState({ health: { acc: { lastSync: Date.now() - 60_000 } } });
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.getByTestId('offline-status').textContent).toContain('usage_updated');
|
||||
});
|
||||
|
||||
it('announces the phase, not every number', () => {
|
||||
const now = Date.now();
|
||||
useOfflineStore.setState({ syncing: ['acc'], progress: { acc: { area: 'mail', phase: 'bodies', done: 500, total: 5000, startedAt: now, startedDone: 0, at: now } } });
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.getByTestId('offline-progress-phase').textContent).toBe('progress_bodies');
|
||||
expect(screen.getByTestId('offline-progress-phase').getAttribute('role')).toBe('status');
|
||||
expect(screen.getByTestId('offline-progress').querySelectorAll('[aria-live]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows a large sync going on, with the time left once it can tell', () => {
|
||||
const now = Date.now();
|
||||
useOfflineStore.setState({
|
||||
syncing: ['acc'],
|
||||
progress: { acc: { area: 'mail', phase: 'bodies', done: 500, total: 5000, startedAt: now - 60_000, startedDone: 0, at: now } },
|
||||
});
|
||||
render(<OfflineSettings />);
|
||||
const line = screen.getByTestId('offline-progress');
|
||||
expect(line.textContent).toContain('progress_bodies');
|
||||
expect(line.textContent).toContain('progress_count');
|
||||
expect(line.textContent).toContain('progress_left');
|
||||
expect(line.querySelector('progress')?.getAttribute('max')).toBe('5000');
|
||||
});
|
||||
|
||||
it('keeps a small catch-up quiet', () => {
|
||||
const now = Date.now();
|
||||
useOfflineStore.setState({ syncing: ['acc'], progress: { acc: { area: 'mail', phase: 'bodies', done: 1, total: 3, startedAt: now, startedDone: 0, at: now } } });
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.queryByTestId('offline-progress')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearing an account', () => {
|
||||
it('asks first, reading then what would be lost: messages and changes', async () => {
|
||||
offline.unsentCount.mockResolvedValue({ messages: 1, changes: 3 });
|
||||
render(<OfflineSettings />);
|
||||
fireEvent.click(screen.getByTestId('offline-clear'));
|
||||
expect(offline.clearAccount).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText(/unsent_changes/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/clear_confirm_unsent/)).toBeInTheDocument();
|
||||
expect(offline.unsentCount).toHaveBeenCalledWith('acc');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'clear_confirm' }));
|
||||
expect(offline.clearAccount).toHaveBeenCalledWith('acc');
|
||||
});
|
||||
|
||||
it('says nothing is lost when nothing is', async () => {
|
||||
offline.unsentCount.mockResolvedValue({ messages: 0, changes: 0 });
|
||||
render(<OfflineSettings />);
|
||||
fireEvent.click(screen.getByTestId('offline-clear'));
|
||||
expect(await screen.findByText('clear_confirm_message')).toBeInTheDocument();
|
||||
await waitFor(() => expect(offline.unsentCount).toHaveBeenCalled());
|
||||
expect(screen.queryByText(/unsent_changes/)).toBeNull();
|
||||
});
|
||||
|
||||
it('can\'t be asked for without a connection, since it downloads everything again', () => {
|
||||
useOfflineStore.setState({ browserOffline: true });
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.getByTestId('offline-clear')).toBeDisabled();
|
||||
expect(screen.getByText('clear_needs_connection')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('nor after a start from the local copy', () => {
|
||||
useOfflineStore.setState({ startedOffline: true });
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.getByTestId('offline-clear')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the off switch', () => {
|
||||
it('asks when only changes wait, naming them', async () => {
|
||||
offline.unsentCount.mockResolvedValue({ messages: 0, changes: 2 });
|
||||
render(<OfflineSettings />);
|
||||
fireEvent.click(screen.getByTestId('offline-switch'));
|
||||
expect(await screen.findByText(/unsent_changes/)).toBeInTheDocument();
|
||||
expect(offline.disableOffline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks nothing when nothing waits', async () => {
|
||||
offline.unsentCount.mockResolvedValue({ messages: 0, changes: 0 });
|
||||
render(<OfflineSettings />);
|
||||
fireEvent.click(screen.getByTestId('offline-switch'));
|
||||
await waitFor(() => expect(offline.disableOffline).toHaveBeenCalledWith('acc'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('what the browser may do', () => {
|
||||
it('warns when it refused to keep the data, and can ask again', async () => {
|
||||
useOfflineStore.setState({ persist: 'refused' });
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.getByTestId('offline-persist-refused').textContent).toContain('persist_refused');
|
||||
await act(async () => { fireEvent.click(screen.getByTestId('offline-persist-retry')); });
|
||||
expect(offline.requestPersistence).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says nothing when it agreed', () => {
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.queryByTestId('offline-persist-refused')).toBeNull();
|
||||
});
|
||||
|
||||
it('warns about a store it deleted, with the count, until read', () => {
|
||||
useOfflineStore.setState({ health: { acc: { reset: { at: 1, lost: 2 } } } });
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.getByTestId('offline-evicted').textContent).toContain('evicted_lost');
|
||||
fireEvent.click(within(screen.getByTestId('offline-evicted')).getByRole('button', { name: 'evicted_dismiss' }));
|
||||
expect(useOfflineStore.getState().health.acc.reset).toBeUndefined();
|
||||
});
|
||||
|
||||
it('says the device is full, with what to do', () => {
|
||||
useOfflineStore.setState({ storageFull: { acc: 1 } });
|
||||
render(<OfflineSettings />);
|
||||
const alert = screen.getByTestId('offline-storage-full');
|
||||
expect(alert.textContent).toContain('storage_full_title');
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'clear_button' }));
|
||||
expect(screen.getByRole('button', { name: 'clear_confirm' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives the Safari hint only in Safari', () => {
|
||||
const real = window.navigator.userAgent;
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.queryByTestId('offline-safari-hint')).toBeNull();
|
||||
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15' });
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.getByTestId('offline-safari-hint')).toBeInTheDocument();
|
||||
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: real });
|
||||
});
|
||||
});
|
||||
|
||||
describe('everything that did not go through', () => {
|
||||
const conflicts = [
|
||||
{ area: 'calendar', account: 'acc', id: 1, createdAt: 1, title: 'Team meeting', reason: 'changed', destroy: false, detail: null },
|
||||
{ area: 'contacts', account: 'acc', id: 2, createdAt: 1, title: 'Ana Silva', reason: 'refused', destroy: false, detail: 'Nope' },
|
||||
] as const;
|
||||
|
||||
it('lists calendar and contacts next to mail, and each can be dismissed', async () => {
|
||||
useOfflineStore.setState({ areaConflicts: [...conflicts] });
|
||||
render(<OfflineSettings />);
|
||||
const rows = screen.getAllByTestId('offline-conflict-row');
|
||||
expect(rows.map((row) => row.getAttribute('data-area'))).toEqual(['calendar', 'contacts']);
|
||||
expect(rows[0].textContent).toContain('Team meeting');
|
||||
fireEvent.click(within(rows[1]).getByRole('button', { name: 'dismiss_label' }));
|
||||
await waitFor(() => expect(offline.dismissAreaConflict).toHaveBeenCalledWith(conflicts[1]));
|
||||
});
|
||||
|
||||
it('counts them all in the banner', () => {
|
||||
useOfflineStore.setState({ areaConflicts: [...conflicts] });
|
||||
render(<OfflineBanner />);
|
||||
expect(screen.getByTestId('offline-banner-conflicts')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('for screen readers', () => {
|
||||
it('names each account\'s group when there are several, and the usage list', () => {
|
||||
useAuthStore.setState({ activeAccountId: 'acc', getAllConnectedClients: (() => new Map([['acc', {}], ['b', {}]])) as never });
|
||||
useAccountStore.setState({ accounts: [{ id: 'acc', label: 'A', email: 'a@example.org' }, { id: 'b', label: 'B', email: 'b@example.org' }] as never });
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.getByRole('group', { name: 'a@example.org' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('group', { name: 'b@example.org' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('offline-usage').getAttribute('aria-label')).toBe('usage_label');
|
||||
useAccountStore.setState({ accounts: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('in the desktop app', () => {
|
||||
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
|
||||
|
||||
it('says what the risk is there, and only there', () => {
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.getByTestId('offline-warning').textContent).toBe('warning');
|
||||
cleanup();
|
||||
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 };
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.getAllByTestId('offline-warning').map((node) => node.textContent)).toEqual(['warning_desktop']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('files kept offline', () => {
|
||||
it('lists the pinned ones with their state, and can let go of one', async () => {
|
||||
render(<OfflineSettings />);
|
||||
const rows = await screen.findAllByTestId('offline-kept-file');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].textContent).toContain('kept_state_kept');
|
||||
expect(rows[1].textContent).toContain('kept_error_overLimit');
|
||||
expect(screen.queryByText('Opened.txt')).toBeNull();
|
||||
fireEvent.click(within(rows[0]).getByRole('button', { name: 'unpin_label' }));
|
||||
await waitFor(() => expect(area).toHaveBeenCalledWith('acc', 'files.unpin', expect.objectContaining({ jmap: 'j', id: 'f1' })));
|
||||
});
|
||||
|
||||
it('an unpin the store refuses leaves the list as it is, with no error thrown about', async () => {
|
||||
render(<OfflineSettings />);
|
||||
const rows = await screen.findAllByTestId('offline-kept-file');
|
||||
area.mockImplementationOnce(async () => { throw new Error('gone'); });
|
||||
fireEvent.click(within(rows[0]).getByRole('button', { name: 'unpin_label' }));
|
||||
await waitFor(() => expect(area).toHaveBeenCalledWith('acc', 'files.unpin', expect.anything()));
|
||||
expect(await screen.findAllByTestId('offline-kept-file')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('a node of another account goes by that account', () => {
|
||||
expect(fileRefFor('shared1:n7', 'own')).toEqual({ jmap: 'shared1', id: 'n7' });
|
||||
expect(fileRefFor('n7', 'own')).toEqual({ jmap: 'own', id: 'n7' });
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@ import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api';
|
||||
function setNoOffline(noOffline: boolean) {
|
||||
usePolicyStore.setState({
|
||||
loaded: true,
|
||||
subject: null,
|
||||
subject: { email: 'alice@example.org', groups: [] },
|
||||
policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, noOffline } } },
|
||||
});
|
||||
}
|
||||
@@ -54,12 +54,12 @@ describe('Settings > Offline', () => {
|
||||
expect(offline.enableOffline).toHaveBeenCalledWith('acc');
|
||||
});
|
||||
|
||||
it('asks before turning off with unsent messages', () => {
|
||||
it('asks before turning off with unsent messages', async () => {
|
||||
useOfflineStore.setState({ enabled: ['acc'], outbox: [{ id: 1, account: 'acc', createdAt: Date.now(), subject: 'Hi', to: ['b@example.org'], status: 'waiting' }] });
|
||||
render(<OfflineSettings />);
|
||||
fireEvent.click(screen.getByTestId('offline-switch'));
|
||||
expect(await screen.findByText('off_confirm_title')).toBeInTheDocument();
|
||||
expect(offline.disableOffline).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('off_confirm_title')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'off_confirm' }));
|
||||
expect(offline.disableOffline).toHaveBeenCalledWith('acc');
|
||||
});
|
||||
@@ -78,7 +78,7 @@ describe('Settings > Offline', () => {
|
||||
expect(screen.queryByTestId('offline-switch')).toBeNull();
|
||||
});
|
||||
|
||||
it('asks before turning off when a draft is only on this device, and counts both lists', () => {
|
||||
it('asks before turning off when a draft is only on this device, and counts both lists', async () => {
|
||||
useOfflineStore.setState({
|
||||
enabled: ['acc'],
|
||||
localDrafts: [{ account: 'acc', localId: 'local-1', jmap: 'j', subject: 'Only here', status: 'waiting', createdAt: 0 }],
|
||||
@@ -86,8 +86,8 @@ describe('Settings > Offline', () => {
|
||||
});
|
||||
render(<OfflineSettings />);
|
||||
fireEvent.click(screen.getByTestId('offline-switch'));
|
||||
expect(await screen.findByText('off_confirm_title')).toBeInTheDocument();
|
||||
expect(offline.disableOffline).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('off_confirm_title')).toBeInTheDocument();
|
||||
useOfflineStore.setState({ localDrafts: [], outbox: [] });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { Lock } from '@/components/icons';
|
||||
import { SettingItem, Select, ToggleSwitch } from '@/components/settings/settings-section';
|
||||
import { OrgLockedNote } from '@/components/settings/org-locked-note';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { formatFileSize } from '@/lib/utils';
|
||||
import { chooseScope, clearAccount, disableOffline, dismissStoreReset, enableOffline, scopeForAccount, unsentCount } from '@/lib/offline/offline-mode';
|
||||
import { attachmentCapOptions, maxAttachmentMb, windowOptions } from '@/lib/offline/scope';
|
||||
import { isOfflineNow, useOfflineStore } from '@/lib/offline/state';
|
||||
import { minutesLeft, progressWorthShowing, type SyncProgress } from '@/lib/offline/sync-progress';
|
||||
import { devicesForAccount } from '@/lib/device-policy';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { KeptFiles } from './kept-files';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
const ALL = 'all';
|
||||
|
||||
function ProgressLine({ progress }: { progress: SyncProgress }) {
|
||||
const t = useTranslations('offlineStorage');
|
||||
const format = useFormatter();
|
||||
const phase = progress.phase === 'list' ? t('progress_list')
|
||||
: progress.phase === 'bodies' ? t('progress_bodies')
|
||||
: progress.phase === 'threads' ? t('progress_threads')
|
||||
: progress.area === 'files' ? t('progress_files')
|
||||
: t('progress_other');
|
||||
const left = minutesLeft(progress);
|
||||
const count = progress.total === null
|
||||
? t('progress_count_open', { done: format.number(progress.done) })
|
||||
: t('progress_count', { done: format.number(progress.done), total: format.number(progress.total) });
|
||||
return (
|
||||
<div className="space-y-1" data-testid="offline-progress">
|
||||
{/* Only the phase is announced: the numbers change every moment. */}
|
||||
<p className="sr-only" role="status" aria-live="polite" data-testid="offline-progress-phase">{phase}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{phase}: {count}{left !== null && <> · {t('progress_left', { minutes: left })}</>}
|
||||
</p>
|
||||
{progress.total !== null && progress.total > 0 && (
|
||||
<progress className="h-1 w-full overflow-hidden rounded-full [&::-webkit-progress-bar]:bg-muted [&::-webkit-progress-value]:bg-primary [&::-moz-progress-bar]:bg-primary" value={Math.min(progress.done, progress.total)} max={progress.total} aria-label={phase} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a clear or switching off would lose, read when the question is asked. */
|
||||
function useUnsent(account: string) {
|
||||
const [unsent, setUnsent] = useState({ messages: 0, changes: 0 });
|
||||
const live = useRef(true);
|
||||
useEffect(() => {
|
||||
live.current = true;
|
||||
return () => { live.current = false; };
|
||||
}, []);
|
||||
const read = async () => {
|
||||
const counted = await unsentCount(account);
|
||||
if (live.current) setUnsent(counted);
|
||||
return counted;
|
||||
};
|
||||
return { unsent, read };
|
||||
}
|
||||
|
||||
/** One account in Settings > Backup & reset > Offline. */
|
||||
export function AccountStorage({ account, primary, heading }: { account: string; primary: boolean; heading: string | null }) {
|
||||
const t = useTranslations('offline.settings');
|
||||
const tStorage = useTranslations('offlineStorage');
|
||||
const format = useFormatter();
|
||||
const enabled = useOfflineStore((s) => s.enabled.includes(account));
|
||||
const usage = useOfflineStore((s) => s.usage[account]);
|
||||
const syncing = useOfflineStore((s) => s.syncing.includes(account));
|
||||
const lastSynced = useOfflineStore((s) => s.lastSyncedAt[account] ?? s.health[account]?.lastSync);
|
||||
const storeReset = useOfflineStore((s) => s.health[account]?.reset);
|
||||
const storageFull = useOfflineStore((s) => !!s.storageFull[account]);
|
||||
const progress = useOfflineStore((s) => s.progress[account]);
|
||||
const server = useAccountStore((s) => s.accounts.find((entry) => entry.id === account)?.serverUrl);
|
||||
const address = useAccountStore((s) => {
|
||||
const entry = s.accounts.find((candidate) => candidate.id === account);
|
||||
return entry?.email || entry?.username || account;
|
||||
});
|
||||
// Clearing downloads everything again, which can't happen without the server.
|
||||
const cantClear = useOfflineStore((s) => s.startedOffline || s.browserOffline || (!!server && isOfflineNow(server)));
|
||||
// Re-renders on a choice; the value itself is worked out below, with the policy.
|
||||
useOfflineStore((s) => s.choices[account]);
|
||||
const { unsent, read: readUnsent } = useUnsent(account);
|
||||
const [confirmOff, setConfirmOff] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const devices = devicesForAccount(account);
|
||||
const { scope, locked } = scopeForAccount(account);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => { setNow(Date.now()); }, [lastSynced, syncing]);
|
||||
|
||||
const testId = (id: string) => (primary ? id : `${id}-other`);
|
||||
|
||||
if (locked.offline) {
|
||||
return (
|
||||
<div className="py-3" data-testid={testId('offline-forbidden')}>
|
||||
{heading && <p className="mb-1 text-sm font-semibold text-foreground [overflow-wrap:anywhere]">{heading}</p>}
|
||||
<p className="text-sm font-medium text-foreground">{t('keep_label')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('forbidden')}</p>
|
||||
<OrgLockedNote className="mt-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const windows = windowOptions(devices.offlineMaxDays);
|
||||
const windowLabel = (days: number | null) => {
|
||||
if (days === null) return tStorage('window_everything');
|
||||
if (days === 365) return tStorage('window_year');
|
||||
return tStorage('window_days', { days });
|
||||
};
|
||||
const caps = attachmentCapOptions(devices.offlineMaxAttachmentMb);
|
||||
const currentCap = maxAttachmentMb(scope);
|
||||
const lostText = [
|
||||
unsent.messages > 0 ? tStorage('clear_confirm_unsent', { count: unsent.messages }) : null,
|
||||
unsent.changes > 0 ? tStorage('unsent_changes', { count: unsent.changes }) : null,
|
||||
].filter(Boolean).join(' ');
|
||||
const askClear = () => {
|
||||
setConfirmClear(true);
|
||||
void readUnsent();
|
||||
};
|
||||
const turnOff = async () => {
|
||||
const counted = await readUnsent();
|
||||
if (counted.messages + counted.changes > 0) setConfirmOff(true);
|
||||
else void disableOffline(account);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={heading ? 'rounded-md border border-border px-3' : undefined}
|
||||
role={heading ? 'group' : undefined}
|
||||
aria-label={heading ?? undefined}
|
||||
data-testid={testId('offline-account')}
|
||||
>
|
||||
{heading && <p className="pt-3 text-sm font-semibold text-foreground [overflow-wrap:anywhere]">{heading}</p>}
|
||||
<SettingItem label={t('keep_label')} description={tStorage('keep_description')} locked={locked.forced}>
|
||||
<ToggleSwitch
|
||||
checked={enabled}
|
||||
testId={testId('offline-switch')}
|
||||
onChange={(on) => {
|
||||
if (on) void enableOffline(account);
|
||||
else void turnOff();
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
{storeReset && (
|
||||
<div className="my-2 flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between" role="alert" data-testid="offline-evicted">
|
||||
<p className="min-w-0">
|
||||
{tStorage('evicted')}
|
||||
{storeReset.lost > 0 && <> {tStorage('evicted_lost', { count: storeReset.lost })}</>}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={() => dismissStoreReset(account)}>{tStorage('evicted_dismiss')}</Button>
|
||||
</div>
|
||||
)}
|
||||
{enabled && (
|
||||
<>
|
||||
{storageFull && (
|
||||
<div className="my-2 flex flex-col gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-foreground sm:flex-row sm:items-center sm:justify-between" role="alert" data-testid="offline-storage-full">
|
||||
<p className="min-w-0"><span className="font-medium">{tStorage('storage_full_title')}</span> {tStorage('storage_full_text')}</p>
|
||||
<Button variant="outline" size="sm" className="shrink-0" disabled={cantClear} onClick={askClear}>{tStorage('clear_button')}</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2 py-3">
|
||||
<p className="text-xs text-muted-foreground" role="status" aria-live="polite" data-testid={testId('offline-status')}>
|
||||
{syncing
|
||||
? t('syncing')
|
||||
: lastSynced
|
||||
? t('usage_updated', { size: formatFileSize(usage?.bytes ?? 0), time: format.relativeTime(lastSynced, now) })
|
||||
: t('usage', { size: formatFileSize(usage?.bytes ?? 0) })}
|
||||
</p>
|
||||
{syncing && progressWorthShowing(progress) && <ProgressLine progress={progress} />}
|
||||
{usage && usage.parts.length > 0 && (
|
||||
<dl className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-xs" aria-label={tStorage('usage_label')} data-testid={testId('offline-usage')}>
|
||||
{usage.parts.filter((part) => part.items > 0).map((part) => (
|
||||
<div key={part.part} className="contents" data-part={part.part}>
|
||||
<dt className="min-w-0 text-muted-foreground">{tStorage(`usage_${part.part}`)}</dt>
|
||||
<dd className="text-end tabular-nums text-foreground">{tStorage('usage_row', { size: formatFileSize(part.bytes), count: part.items })}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
<SettingItem label={tStorage('window_label')} description={tStorage('window_description')} locked={windows.length <= 1}>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<Select
|
||||
value={scope.days === null ? ALL : String(scope.days)}
|
||||
onChange={(value) => chooseScope(account, { days: value === ALL ? null : Number(value) })}
|
||||
options={windows.map((days) => ({ value: days === null ? ALL : String(days), label: windowLabel(days) }))}
|
||||
/>
|
||||
{locked.days && windows.length > 1 && <OrgLockedNote />}
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label={tStorage('attachments_label')}
|
||||
description={tStorage('attachments_description', { total: formatFileSize(scope.maxTotalBytes) })}
|
||||
locked={locked.attachments}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={scope.attachments}
|
||||
testId={testId('offline-attachments')}
|
||||
onChange={(on) => chooseScope(account, { attachments: on })}
|
||||
/>
|
||||
</SettingItem>
|
||||
{locked.maxTotal && !locked.attachments && (
|
||||
<p className="flex items-center gap-1 pb-2 text-xs text-muted-foreground" data-testid={testId('offline-total-locked')}>
|
||||
<Lock className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
{tStorage('total_locked', { size: formatFileSize(scope.maxTotalBytes) })}
|
||||
</p>
|
||||
)}
|
||||
{scope.attachments && (
|
||||
<SettingItem label={tStorage('attachment_cap_label')} nested locked={caps.length <= 1}>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<Select
|
||||
value={String(currentCap)}
|
||||
onChange={(value) => chooseScope(account, { maxAttachmentBytes: Number(value) * MB })}
|
||||
options={(caps.includes(currentCap) ? caps : [...caps, currentCap].sort((a, b) => a - b)).map((mb) => ({ value: String(mb), label: formatFileSize(mb * MB) }))}
|
||||
/>
|
||||
{locked.maxAttachment && caps.length > 1 && <OrgLockedNote />}
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
<KeptFiles account={account} />
|
||||
<SettingItem label={tStorage('clear_label')} description={cantClear ? tStorage('clear_needs_connection') : tStorage('clear_description')}>
|
||||
<Button variant="outline" size="sm" disabled={cantClear} onClick={askClear} data-testid={testId('offline-clear')}>{tStorage('clear_button')}</Button>
|
||||
</SettingItem>
|
||||
</>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
isOpen={confirmOff}
|
||||
onClose={() => setConfirmOff(false)}
|
||||
onConfirm={() => { setConfirmOff(false); void disableOffline(account); }}
|
||||
title={t('off_confirm_title')}
|
||||
message={`${tStorage('off_confirm_text', { account: address })} ${lostText}`.trim()}
|
||||
confirmText={t('off_confirm')}
|
||||
variant="destructive"
|
||||
/>
|
||||
<ConfirmDialog
|
||||
isOpen={confirmClear}
|
||||
onClose={() => setConfirmClear(false)}
|
||||
onConfirm={() => { setConfirmClear(false); void clearAccount(account); }}
|
||||
title={tStorage('clear_confirm_title')}
|
||||
message={`${tStorage('clear_confirm_message', { account: address })} ${lostText}`.trim()}
|
||||
confirmText={tStorage('clear_confirm')}
|
||||
variant="destructive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatFileSize } from '@/lib/utils';
|
||||
import { useKeptFiles } from '@/hooks/use-kept-files';
|
||||
|
||||
/** The files this account keeps until told otherwise, in Settings > Backup & reset > Offline. */
|
||||
export function KeptFiles({ account }: { account: string }) {
|
||||
const t = useTranslations('offlineStorage');
|
||||
const { pinned, unpin } = useKeptFiles(account);
|
||||
if (pinned.length === 0) return null;
|
||||
return (
|
||||
<div className="border-b border-border py-3 last:border-0" data-testid="offline-kept-files">
|
||||
<h4 className="text-sm font-medium text-foreground">{t('kept_files_title')}</h4>
|
||||
<ul className="mt-2 divide-y divide-border rounded-md border border-border" aria-label={t('kept_files_title')}>
|
||||
{pinned.map((file) => (
|
||||
<li key={`${file.jmap}:${file.id}`} className="flex flex-col gap-2 px-3 py-2 sm:flex-row sm:items-center sm:justify-between" data-testid="offline-kept-file" data-kept={file.kept}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-foreground">{file.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{file.size !== null && <>{formatFileSize(file.size)} · </>}
|
||||
{file.kept ? t('kept_state_kept') : file.error ? t(`kept_error_${file.error}` as 'kept_error_download') : t('kept_state_waiting')}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" aria-label={t('unpin_label', { name: file.name })} onClick={() => void unpin(file)}>{t('unpin')}</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export function OfflineBanner() {
|
||||
const browserOffline = useOfflineStore((s) => s.browserOffline || s.startedOffline);
|
||||
const serverDown = useOfflineStore((s) => !!server && s.downServers.includes(serverKey(server)));
|
||||
const waiting = useOfflineStore((s) => s.outbox.length);
|
||||
const conflicts = useOfflineStore((s) => s.conflicts.length);
|
||||
const conflicts = useOfflineStore((s) => s.conflicts.length + s.areaConflicts.length);
|
||||
const refusedDrafts = useOfflineStore((s) => s.localDrafts.filter((draft) => draft.status === 'refused').length);
|
||||
const waitingDrafts = useOfflineStore((s) => s.localDrafts.filter((draft) => draft.status !== 'refused').length);
|
||||
const tConflicts = useTranslations('offlineConflicts');
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
|
||||
import { addSignOutGate } from '@/lib/sign-out-cleanup';
|
||||
import { reloadToNewBuild } from '@/lib/app-update';
|
||||
import { buildSettingsPath } from '@/lib/deep-links';
|
||||
import { confirmLoss, setOfflineNotifier, startOfflineMode, unsentCount, type OfflineNotice } from '@/lib/offline/offline-mode';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
@@ -22,6 +24,12 @@ export function OfflineHost() {
|
||||
const tConflicts = useTranslations('offlineConflicts');
|
||||
const tConflictsRef = useRef(tConflicts);
|
||||
tConflictsRef.current = tConflicts;
|
||||
const tStorage = useTranslations('offlineStorage');
|
||||
const tStorageRef = useRef(tStorage);
|
||||
tStorageRef.current = tStorage;
|
||||
const router = useRouter();
|
||||
const routerRef = useRef(router);
|
||||
routerRef.current = router;
|
||||
const tDrafts = useTranslations('offlineDrafts');
|
||||
const tDraftsRef = useRef(tDrafts);
|
||||
tDraftsRef.current = tDrafts;
|
||||
@@ -32,6 +40,7 @@ export function OfflineHost() {
|
||||
useEffect(() => {
|
||||
const show = (notice: OfflineNotice) => {
|
||||
const tr = tRef.current;
|
||||
const openStorage = { label: tStorageRef.current('open_settings'), onClick: () => routerRef.current.push(buildSettingsPath('backup', 'offline')) };
|
||||
switch (notice.kind) {
|
||||
case 'queued':
|
||||
toast.info(tr('toast.queued'));
|
||||
@@ -62,13 +71,20 @@ export function OfflineHost() {
|
||||
toast.warning(tDraftsRef.current('toast_conflict', { subject: notice.subject || tr('outbox.no_subject') }));
|
||||
break;
|
||||
case 'storage-failed':
|
||||
toast.warning(tConflictsRef.current('storage_failed'), { duration: 0 });
|
||||
toast.warning(tStorageRef.current('storage_full_title'), { message: tStorageRef.current('storage_full_text'), duration: 0, action: openStorage });
|
||||
break;
|
||||
case 'default-on':
|
||||
toast.info(tStorageRef.current('default_on'), { duration: 0, action: openStorage });
|
||||
break;
|
||||
case 'legacy-stranded':
|
||||
toast.warning(tConflictsRef.current('legacy_stranded'), { duration: 0 });
|
||||
break;
|
||||
case 'store-reset':
|
||||
toast.warning(tConflictsRef.current('store_reset'), { duration: 0 });
|
||||
toast.warning(tConflictsRef.current('store_reset'), {
|
||||
message: notice.lost ? tStorageRef.current('evicted_lost', { count: notice.lost }) : undefined,
|
||||
duration: 0,
|
||||
action: openStorage,
|
||||
});
|
||||
break;
|
||||
case 'store-unavailable':
|
||||
toast.warning(tConflictsRef.current('store_unavailable'), { duration: 0 });
|
||||
@@ -114,7 +130,7 @@ export function OfflineHost() {
|
||||
return () => {
|
||||
removeGate();
|
||||
stop();
|
||||
setOfflineNotifier(() => {});
|
||||
setOfflineNotifier(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,46 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AlertTriangle } from '@/components/icons';
|
||||
import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section';
|
||||
import { OrgLockedNote } from '@/components/settings/org-locked-note';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { formatFileSize } from '@/lib/utils';
|
||||
import { disableOffline, enableOffline, offlineAllowed, offlineSupported } from '@/lib/offline/offline-mode';
|
||||
import { isDesktopApp } from '@/lib/desktop/bridge';
|
||||
import { signedInAccountIds } from '@/lib/device-policy';
|
||||
import { offlineSupported, readPersistence, refreshUsage, requestPersistence, safariMayDelete } from '@/lib/offline/offline-mode';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
import { useDevicePolicy } from '@/hooks/use-device-policy';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { AccountStorage } from './account-storage';
|
||||
import { OutboxList } from './outbox-list';
|
||||
import { WipeDeviceSection } from './wipe-device-section';
|
||||
|
||||
/**
|
||||
* Settings > Backup & reset > Offline (#523): "Keep mail on this device"
|
||||
* for the active account, the space it uses, and the outbox. Not in Lite.
|
||||
* When the organisation forbids it, the switch is gone and a note says so.
|
||||
* Settings > Backup & reset > Offline (#523): for each account signed in here,
|
||||
* whether it keeps mail on this device, how much, what that takes, and the way
|
||||
* to clear it. Above that what the browser may do to it, below it the outbox
|
||||
* and whatever didn't go through. Not in Lite.
|
||||
*/
|
||||
export function OfflineSettings() {
|
||||
const t = useTranslations('offline.settings');
|
||||
const format = useFormatter();
|
||||
const account = useAuthStore((s) => s.activeAccountId);
|
||||
const tStorage = useTranslations('offlineStorage');
|
||||
const active = useAuthStore((s) => s.activeAccountId);
|
||||
useAuthStore((s) => s.connectedAccountsRevision);
|
||||
// Re-renders when the organisation's device settings change.
|
||||
const devices = useDevicePolicy();
|
||||
const enabled = useOfflineStore((s) => !!account && s.enabled.includes(account));
|
||||
const usage = useOfflineStore((s) => s.usageBytes);
|
||||
const syncing = useOfflineStore((s) => !!account && s.syncing.includes(account));
|
||||
const lastSynced = useOfflineStore((s) => (account ? s.lastSyncedAt[account] : undefined));
|
||||
const unsent = useOfflineStore((s) => [...s.outbox, ...s.localDrafts].filter((row) => row.account === account).length);
|
||||
useDevicePolicy();
|
||||
const labels = useAccountStore((s) => s.accounts);
|
||||
const enabledCount = useOfflineStore((s) => s.enabled.length);
|
||||
const persist = useOfflineStore((s) => s.persist);
|
||||
const blocked = useOfflineStore((s) => s.storageBlocked);
|
||||
const tNotes = useTranslations('offlineConflicts');
|
||||
const [confirmOff, setConfirmOff] = useState(false);
|
||||
const [asking, setAsking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (IS_LITE || enabledCount === 0) return;
|
||||
void readPersistence();
|
||||
void refreshUsage();
|
||||
}, [enabledCount]);
|
||||
|
||||
if (IS_LITE) return null;
|
||||
const allowed = !devices.noOffline && offlineAllowed(account);
|
||||
|
||||
const turnOff = () => {
|
||||
if (!account) return;
|
||||
void disableOffline(account);
|
||||
const accounts = [...new Set([...(active ? [active] : []), ...signedInAccountIds()])];
|
||||
const several = accounts.length > 1;
|
||||
const askAgain = async () => {
|
||||
setAsking(true);
|
||||
await requestPersistence();
|
||||
setAsking(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -49,52 +59,33 @@ export function OfflineSettings() {
|
||||
<p className="text-sm text-muted-foreground" data-testid="offline-private">{tNotes('store_private')}</p>
|
||||
) : !offlineSupported() ? (
|
||||
<p className="text-sm text-muted-foreground" data-testid="offline-unsupported">{t('unsupported')}</p>
|
||||
) : !allowed ? (
|
||||
<div className="py-3" data-testid="offline-forbidden">
|
||||
<p className="text-sm font-medium text-foreground">{t('keep_label')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('forbidden')}</p>
|
||||
<OrgLockedNote className="mt-1" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SettingItem label={t('keep_label')} description={t('keep_description')}>
|
||||
<ToggleSwitch
|
||||
checked={enabled}
|
||||
testId="offline-switch"
|
||||
onChange={(on) => {
|
||||
if (!account) return;
|
||||
if (on) void enableOffline(account);
|
||||
else if (unsent > 0) setConfirmOff(true);
|
||||
else turnOff();
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<p>{t('warning')}</p>
|
||||
<p data-testid="offline-warning">{isDesktopApp() ? tStorage('warning_desktop') : t('warning')}</p>
|
||||
</div>
|
||||
{enabled && (
|
||||
<p className="text-xs text-muted-foreground" role="status" aria-live="polite" data-testid="offline-status">
|
||||
{syncing
|
||||
? t('syncing')
|
||||
: lastSynced
|
||||
? t('usage_updated', { size: formatFileSize(usage), time: format.relativeTime(lastSynced, Date.now()) })
|
||||
: t('usage', { size: formatFileSize(usage) })}
|
||||
</p>
|
||||
{enabledCount > 0 && persist === 'refused' && (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between" role="status" data-testid="offline-persist-refused">
|
||||
<p className="min-w-0">{tStorage('persist_refused')}</p>
|
||||
<Button variant="outline" size="sm" className="shrink-0" disabled={asking} onClick={() => void askAgain()} data-testid="offline-persist-retry">{tStorage('persist_retry')}</Button>
|
||||
</div>
|
||||
)}
|
||||
{enabledCount > 0 && safariMayDelete() && (
|
||||
<p className="rounded-md border border-border bg-muted px-3 py-2 text-xs text-foreground" data-testid="offline-safari-hint">{tStorage('safari_hint')}</p>
|
||||
)}
|
||||
{accounts.map((account, index) => (
|
||||
<AccountStorage
|
||||
key={account}
|
||||
account={account}
|
||||
primary={index === 0}
|
||||
heading={several ? labels.find((entry) => entry.id === account)?.email || labels.find((entry) => entry.id === account)?.label || account : null}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<OutboxList />
|
||||
<WipeDeviceSection />
|
||||
<ConfirmDialog
|
||||
isOpen={confirmOff}
|
||||
onClose={() => setConfirmOff(false)}
|
||||
onConfirm={() => { setConfirmOff(false); turnOff(); }}
|
||||
title={t('off_confirm_title')}
|
||||
message={t('off_confirm_message', { count: unsent })}
|
||||
confirmText={t('off_confirm')}
|
||||
variant="destructive"
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import { usePathname, useRouter } from '@/i18n/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { deleteOutboxEntry, retryOutboxEntry } from '@/lib/offline/flush';
|
||||
import { refreshQueueState, scheduleFlushAll } from '@/lib/offline/offline-mode';
|
||||
import { dismissAreaConflict, refreshQueueState, scheduleFlushAll } from '@/lib/offline/offline-mode';
|
||||
import { dismissConflict, retryLocalDraft, type ConflictEntry, type LocalDraftRow } from '@/lib/offline/queue';
|
||||
import { isOfflineNow, useOfflineStore, type OutboxRow } from '@/lib/offline/state';
|
||||
import { isOfflineNow, useOfflineStore, type AreaConflict, type OutboxRow } from '@/lib/offline/state';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
/**
|
||||
@@ -25,6 +25,7 @@ export function OutboxList({ heading = true, onOpenDraft }: { heading?: boolean;
|
||||
const t = useTranslations('offline.outbox');
|
||||
const tErrors = useTranslations('offline.errors');
|
||||
const tConflicts = useTranslations('offlineConflicts');
|
||||
const tStorage = useTranslations('offlineStorage');
|
||||
const tDrafts = useTranslations('offlineDrafts');
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -33,6 +34,7 @@ export function OutboxList({ heading = true, onOpenDraft }: { heading?: boolean;
|
||||
const rows = useOfflineStore((s) => s.outbox);
|
||||
const changes = useOfflineStore((s) => s.pendingChanges);
|
||||
const conflicts = useOfflineStore((s) => s.conflicts);
|
||||
const areaConflicts = useOfflineStore((s) => s.areaConflicts);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const [deleting, setDeleting] = useState<OutboxRow | null>(null);
|
||||
const several = new Set(rows.map((r) => r.account)).size > 1 || accounts.length > 1;
|
||||
@@ -81,6 +83,12 @@ export function OutboxList({ heading = true, onOpenDraft }: { heading?: boolean;
|
||||
return tConflicts(conflict.summary.op === 'destroy' ? 'changed_delete' : 'changed');
|
||||
};
|
||||
|
||||
const areaConflictText = (conflict: AreaConflict) => {
|
||||
if (conflict.reason === 'refused') return tConflicts('refused', { reason: conflict.detail ?? '' });
|
||||
if (conflict.reason === 'gone') return tConflicts('gone');
|
||||
return tConflicts(conflict.destroy ? 'changed_delete' : 'changed');
|
||||
};
|
||||
|
||||
const dismiss = async (conflict: ConflictEntry) => {
|
||||
await dismissConflict(conflict.account, conflict.id);
|
||||
await refreshQueueState();
|
||||
@@ -144,7 +152,7 @@ export function OutboxList({ heading = true, onOpenDraft }: { heading?: boolean;
|
||||
{changes > 0 && (
|
||||
<p className="mt-2 text-xs text-muted-foreground" data-testid="offline-pending-changes">{t('changes', { count: changes })}</p>
|
||||
)}
|
||||
{conflicts.length > 0 && (
|
||||
{conflicts.length + areaConflicts.length > 0 && (
|
||||
<div className="mt-3" data-testid="offline-conflicts">
|
||||
<p className="text-xs text-muted-foreground">{tConflicts('description')}</p>
|
||||
<ul className="mt-2 divide-y divide-border rounded-md border border-border" aria-label={tConflicts('title')}>
|
||||
@@ -161,6 +169,21 @@ export function OutboxList({ heading = true, onOpenDraft }: { heading?: boolean;
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{areaConflicts.map((conflict) => (
|
||||
<li key={`${conflict.account}:${conflict.area}:${conflict.id}`} className="flex flex-col gap-2 px-3 py-2.5 sm:flex-row sm:items-start sm:justify-between" data-testid="offline-conflict-row" data-area={conflict.area} data-reason={conflict.reason}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{tStorage(conflict.area === 'calendar' ? 'conflict_calendar' : 'conflict_contacts')}: {conflict.title || t('no_subject')}
|
||||
</p>
|
||||
<p className="text-xs text-destructive [overflow-wrap:anywhere]">
|
||||
{areaConflictText(conflict)} · {format.dateTime(conflict.createdAt, { dateStyle: 'short', timeStyle: 'short' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button variant="outline" size="sm" aria-label={tConflicts('dismiss_label', { subject: conflict.title || t('no_subject') })} onClick={() => void dismissAreaConflict(conflict)}>{tConflicts('dismiss')}</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRef, useState, type DragEvent, type KeyboardEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings, PenSquare, MailOpen, Folder, Search, X, type AppIcon } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRestoredTabTitles } from "@/hooks/use-restored-tab-titles";
|
||||
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
|
||||
import {
|
||||
PRO_TAB_DRAG_MIME,
|
||||
@@ -63,6 +64,7 @@ export function ProTabBar({
|
||||
}: ProTabBarProps) {
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const reorderTab = useProTabStore((s) => s.reorderTab);
|
||||
useRestoredTabTitles(tabs);
|
||||
|
||||
const [dropIndicator, setDropIndicator] = useState<DropIndicator>(null);
|
||||
// True while an email or folder payload hovers the strip (both drop "anywhere").
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { useKeepFileOffline } from '../use-keep-file-offline';
|
||||
|
||||
const FILE = { id: 'f1' };
|
||||
const client = { getAccountId: () => 'j' } as never;
|
||||
const area = vi.fn(async (_account: string, _name: string, _params?: unknown): Promise<unknown> => ({ files: [] }));
|
||||
|
||||
function setDevices(devices: Partial<typeof DEFAULT_ORGANISATION_POLICY.devices>) {
|
||||
usePolicyStore.setState({
|
||||
loaded: true, source: 'server', subject: { email: 'a@example.org', groups: [] },
|
||||
policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } },
|
||||
} as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
area.mockClear();
|
||||
area.mockImplementation(async () => ({ files: [] }));
|
||||
setStoreBackend({ supported: () => true, area } as unknown as StoreBackend);
|
||||
useAuthStore.setState({ activeAccountId: 'acc' });
|
||||
useOfflineStore.setState({ enabled: ['acc'], choices: { acc: { on: true } } });
|
||||
useToastStore.setState({ toasts: [] } as never);
|
||||
setDevices({});
|
||||
});
|
||||
|
||||
afterEach(() => { setStoreBackend(null); });
|
||||
|
||||
describe('Keep offline in the Files app', () => {
|
||||
it('is offered for a plain file, not for a folder', () => {
|
||||
const { result } = renderHook(() => useKeepFileOffline(client));
|
||||
expect(result.current.canKeepOffline(FILE)).toBe(true);
|
||||
expect(result.current.canKeepOffline({ ...FILE, isDirectory: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('isn\'t offered when the organisation forbids keeping files', () => {
|
||||
setDevices({ noOfflineAttachments: true });
|
||||
const { result } = renderHook(() => useKeepFileOffline(client));
|
||||
expect(result.current.canKeepOffline(FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it('isn\'t offered when the user keeps no attachments', () => {
|
||||
useOfflineStore.setState({ choices: { acc: { on: true, attachments: false } } });
|
||||
const { result } = renderHook(() => useKeepFileOffline(client));
|
||||
expect(result.current.canKeepOffline(FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it('says so when the store has no row for the file yet', async () => {
|
||||
area.mockImplementation(async (_account, name) => {
|
||||
if (name === 'files.pin') throw new Error('No file f1');
|
||||
return { files: [] };
|
||||
});
|
||||
const { result } = renderHook(() => useKeepFileOffline(client));
|
||||
await act(async () => { await result.current.toggleKeptOffline(FILE); });
|
||||
await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain('pin_not_ready'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { TITLE_UNTIL_LOADED, useProTabStore, type ProTab } from '@/stores/pro-tab-store';
|
||||
import { useRestoredTabTitles } from '../use-restored-tab-titles';
|
||||
|
||||
const stored = (id: string, title = TITLE_UNTIL_LOADED): ProTab => ({
|
||||
id, kind: 'email', labelKey: '', title, closeable: true, paneId: 'main',
|
||||
emailData: { accountId: 'j', emailId: `m-${id}`, mailboxId: null, title },
|
||||
});
|
||||
|
||||
const getEmail = vi.fn(async (id: string) => ({ subject: id === 'm-empty' ? '' : `Subject of ${id}` }));
|
||||
|
||||
function current(id: string) {
|
||||
return useProTabStore.getState().tabs.find((tab) => tab.id === id);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getEmail.mockClear();
|
||||
useAuthStore.setState({ client: { getEmail } as never });
|
||||
});
|
||||
|
||||
describe('tabs restored without their subjects', () => {
|
||||
it('get them back without being opened, also an empty one', async () => {
|
||||
const tabs = [stored('a'), stored('empty'), stored('known', 'Known')];
|
||||
useProTabStore.setState({ tabs });
|
||||
renderHook(() => useRestoredTabTitles(tabs));
|
||||
await waitFor(() => expect(current('a')?.title).toBe('Subject of m-a'));
|
||||
expect(current('a')?.emailData?.title).toBe('Subject of m-a');
|
||||
await waitFor(() => expect(current('empty')?.title).toBe('no_subject'));
|
||||
expect(getEmail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('wait for a client, and keep their mark if the message can\'t be read', async () => {
|
||||
const tabs = [stored('a')];
|
||||
useProTabStore.setState({ tabs });
|
||||
useAuthStore.setState({ client: null });
|
||||
const { rerender } = renderHook(() => useRestoredTabTitles(tabs));
|
||||
expect(getEmail).not.toHaveBeenCalled();
|
||||
getEmail.mockRejectedValueOnce(new Error('offline'));
|
||||
useAuthStore.setState({ client: { getEmail } as never });
|
||||
rerender();
|
||||
await waitFor(() => expect(getEmail).toHaveBeenCalled());
|
||||
expect(current('a')?.title).toBe(TITLE_UNTIL_LOADED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useDevicePolicy } from '@/hooks/use-device-policy';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { scopeForAccount } from '@/lib/offline/offline-mode';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useKeptFiles } from './use-kept-files';
|
||||
|
||||
interface FileRef {
|
||||
id: string;
|
||||
isDirectory?: boolean;
|
||||
isSymlink?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nodes of another account are listed as "accountId:nodeId" (stores/file-store.ts).
|
||||
* The store keeps them under the account they live in.
|
||||
*/
|
||||
export function fileRefFor(id: string, ownAccount: string): { jmap: string; id: string } {
|
||||
const at = id.indexOf(':');
|
||||
return at > 0 ? { jmap: id.slice(0, at), id: id.slice(at + 1) } : { jmap: ownAccount, id };
|
||||
}
|
||||
|
||||
/** "Keep offline" in the Files app. Nothing to offer when the account doesn't keep mail here. */
|
||||
export function useKeepFileOffline(client: IJMAPClient | null | undefined) {
|
||||
const t = useTranslations('offlineStorage');
|
||||
const account = useAuthStore((s) => s.activeAccountId);
|
||||
const { active, pinned, pin, unpin } = useKeptFiles(account);
|
||||
const own = client?.getAccountId?.() ?? '';
|
||||
|
||||
const keptOffline = useMemo(() => new Set(pinned.map((file) => (file.jmap === own ? file.id : `${file.jmap}:${file.id}`))), [pinned, own]);
|
||||
|
||||
// Nothing to offer where the organisation or the user keeps no files.
|
||||
const devices = useDevicePolicy();
|
||||
useOfflineStore((s) => (account ? s.choices[account] : undefined));
|
||||
const attachmentsAllowed = !devices.noOfflineAttachments && !!account && scopeForAccount(account).scope.attachments;
|
||||
|
||||
const canKeepOffline = useCallback(
|
||||
(resource: FileRef) => active && attachmentsAllowed && !!own && !resource.isDirectory && !resource.isSymlink,
|
||||
[active, attachmentsAllowed, own],
|
||||
);
|
||||
|
||||
const toggleKeptOffline = useCallback(async (resource: FileRef) => {
|
||||
const ref = fileRefFor(resource.id, own);
|
||||
if (keptOffline.has(resource.id)) {
|
||||
await unpin(ref);
|
||||
return;
|
||||
}
|
||||
// A file so new the store hasn't listed it yet has nothing to pin.
|
||||
await pin(ref).catch(() => toast.info(t('pin_not_ready')));
|
||||
}, [own, keptOffline, pin, unpin, t]);
|
||||
|
||||
return { keptOffline, canKeepOffline, toggleKeptOffline };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { getStore } from '@/lib/store/client/api';
|
||||
import { offlineActive } from '@/lib/offline/offline-mode';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
|
||||
/** A row of the Files area's `files.kept`. */
|
||||
export interface KeptFile {
|
||||
jmap: string;
|
||||
id: string;
|
||||
name: string;
|
||||
size: number | null;
|
||||
pinned: boolean;
|
||||
kept: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The files an account keeps offline, and pinning them. Nothing when the
|
||||
* account doesn't keep mail here, or the build has no Files area.
|
||||
*/
|
||||
export function useKeptFiles(account: string | null | undefined) {
|
||||
const [files, setFiles] = useState<KeptFile[]>([]);
|
||||
const lastSynced = useOfflineStore((s) => (account ? s.lastSyncedAt[account] : undefined));
|
||||
const active = useOfflineStore((s) => !!account && s.enabled.includes(account)) && offlineActive(account);
|
||||
|
||||
const read = useCallback(async () => {
|
||||
if (!account || !active) {
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setFiles((await getStore().area<{ files: KeptFile[] }>(account, 'files.kept'))?.files ?? []);
|
||||
} catch {
|
||||
// The store is starting or being deleted, or this build has no Files area.
|
||||
setFiles([]);
|
||||
}
|
||||
}, [account, active]);
|
||||
|
||||
useEffect(() => { void read(); }, [read, lastSynced]);
|
||||
|
||||
const pinned = useMemo(() => files.filter((file) => file.pinned), [files]);
|
||||
|
||||
const pin = useCallback(async (ref: { jmap: string; id: string }) => {
|
||||
if (!account) return;
|
||||
await getStore().area(account, 'files.pin', ref);
|
||||
await read();
|
||||
}, [account, read]);
|
||||
|
||||
const unpin = useCallback(async (ref: { jmap: string; id: string }) => {
|
||||
if (!account) return;
|
||||
// The store being in trouble: the list says what's so.
|
||||
await getStore().area(account, 'files.unpin', ref).catch(() => {});
|
||||
await read();
|
||||
}, [account, read]);
|
||||
|
||||
return { active, files, pinned, pin, unpin };
|
||||
}
|
||||
@@ -13,9 +13,34 @@ export interface PluginRenderedBody {
|
||||
text: string | null;
|
||||
/** blob: URLs for the decrypted inline parts, keyed by Content-ID without brackets. */
|
||||
cidUrls: Record<string, string>;
|
||||
/** The plugin can't vouch for this body: external content stays off, whatever the settings allow. */
|
||||
blockExternal: boolean;
|
||||
}
|
||||
|
||||
const EMPTY: PluginRenderedBody = { html: null, text: null, cidUrls: {} };
|
||||
const EMPTY: PluginRenderedBody = { html: null, text: null, cidUrls: {}, blockExternal: false };
|
||||
|
||||
/**
|
||||
* What onRenderEmailBody gets to see, for its effect's dependencies. A render can be a
|
||||
* key operation (a smart card's PIN dialog, and two cancels in a row lock the key), so
|
||||
* it runs once per message, not per object: mark-read replaces the object and changes
|
||||
* only keywords. The list row and then the full message do differ here: a row has no
|
||||
* headers or body structure, which is what a plugin recognises its mail by. A plugin
|
||||
* that wants to render again for another reason has api.ui.rerenderEmail.
|
||||
*/
|
||||
export function pluginRenderKey(email: Email | null | undefined): string | null {
|
||||
if (!email) return null;
|
||||
const contentType = email.headers?.['content-type'] ?? email.headers?.['Content-Type'] ?? '';
|
||||
const bodyLengths = Object.values(email.bodyValues ?? {}).map((part) => part.value?.length ?? 0);
|
||||
return JSON.stringify([
|
||||
email.sourceClientAccountId ?? '',
|
||||
email.id,
|
||||
email.blobId ?? '',
|
||||
contentType,
|
||||
email.bodyStructure ? 1 : 0,
|
||||
email.bodyValues ? bodyLengths : null,
|
||||
email.attachments?.length ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
function dataUrlToBytes(dataUrl: string): Uint8Array | null {
|
||||
try {
|
||||
@@ -39,7 +64,8 @@ function dataUrlToBytes(dataUrl: string): Uint8Array | null {
|
||||
* Asks the onRenderEmailBody hook for a replacement body, the way the single
|
||||
* reader does, so a message a PGP or S/MIME plugin decrypts also shows
|
||||
* decrypted inside a conversation (#902). Re-runs when a plugin calls
|
||||
* api.ui.rerenderEmail, e.g. after the user unlocked a key.
|
||||
* api.ui.rerenderEmail, e.g. after the user unlocked a key, and not when only the
|
||||
* object changes (see pluginRenderKey).
|
||||
*/
|
||||
export function usePluginRenderedBody(email: Email): PluginRenderedBody {
|
||||
const [body, setBody] = useState<PluginRenderedBody>(EMPTY);
|
||||
@@ -79,6 +105,7 @@ export function usePluginRenderedBody(email: Email): PluginRenderedBody {
|
||||
text?: string;
|
||||
attachments?: Array<{ name?: string; type?: string; dataUrl?: string; cid?: string }>;
|
||||
handledBy?: string;
|
||||
blockExternalContent?: boolean;
|
||||
};
|
||||
if (cancelled) return;
|
||||
if (!result || !result.handledBy) {
|
||||
@@ -100,6 +127,7 @@ export function usePluginRenderedBody(email: Email): PluginRenderedBody {
|
||||
html: typeof result.html === 'string' && result.html ? result.html : null,
|
||||
text: typeof result.text === 'string' && result.text ? result.text : null,
|
||||
cidUrls,
|
||||
blockExternal: result.blockExternalContent === true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
@@ -112,7 +140,9 @@ export function usePluginRenderedBody(email: Email): PluginRenderedBody {
|
||||
cancelled = true;
|
||||
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [email, nonce]);
|
||||
// The latest message is read inside; see pluginRenderKey for what should run this again.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pluginRenderKey(email), nonce]);
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { TITLE_UNTIL_LOADED, useProTabStore, type ProTab } from '@/stores/pro-tab-store';
|
||||
|
||||
/**
|
||||
* With the local store on, stored tabs have no subjects. A tab's body puts its
|
||||
* title back, but only an open tab has a body, so the others are asked for here.
|
||||
*/
|
||||
export function useRestoredTabTitles(tabs: readonly ProTab[]): void {
|
||||
const t = useTranslations('email_composer');
|
||||
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const waiting = tabs.filter((tab) => tab.kind === 'email' && tab.title === TITLE_UNTIL_LOADED && tab.emailData);
|
||||
const key = waiting.map((tab) => tab.id).join(' ');
|
||||
|
||||
useEffect(() => {
|
||||
if (!key || !client) return;
|
||||
let stale = false;
|
||||
for (const tab of waiting) {
|
||||
const data = tab.emailData!;
|
||||
const owner = data.clientAccountId ? useAuthStore.getState().getClientForAccount(data.clientAccountId) : client;
|
||||
// Its login is gone or not back yet. The next render asks again.
|
||||
if (!owner) continue;
|
||||
void owner.getEmail(data.emailId, data.accountId).then((email) => {
|
||||
if (!stale && email) updateTabTitle(tab.id, email.subject || t('no_subject'));
|
||||
}).catch(() => {});
|
||||
}
|
||||
return () => { stale = true; };
|
||||
// `waiting` follows `key`.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, client, updateTabTitle]);
|
||||
}
|
||||
@@ -938,6 +938,392 @@ test('at 390 px: the offline settings, the outbox and the reload prompt fit', as
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
// ---- What a device keeps (design A4, A7, A8, A9) ----
|
||||
|
||||
/** A message from long ago, in ent-alice's Inbox. Email/import takes the received date. */
|
||||
async function importOld(account: string, subject: string, daysAgo: number): Promise<void> {
|
||||
const client = await jmapAs(account);
|
||||
const receivedAt = new Date(Date.now() - daysAgo * 86_400_000).toISOString().replace(/\.\d+Z$/, 'Z');
|
||||
const raw = `From: ${ENT.bob}\r\nTo: ${account}\r\nSubject: ${subject}\r\nDate: ${new Date(receivedAt).toUTCString()}\r\nMessage-ID: <${subject.replace(/\W/g, '')}@example.org>\r\nContent-Type: text/plain\r\n\r\nOld body ${RUN}\r\n`;
|
||||
const auth = 'Basic ' + Buffer.from(`${account}:${process.env.IT_ENT_PASSWORD}`).toString('base64');
|
||||
const upload = await fetch(`${JMAP_URL}/jmap/upload/${client.accountId}/`, { method: 'POST', headers: { Authorization: auth, 'Content-Type': 'message/rfc822' }, body: raw });
|
||||
if (!upload.ok) throw new Error(`upload: ${upload.status}`);
|
||||
const { blobId } = await upload.json() as { blobId: string };
|
||||
const boxes = await client.request([['Mailbox/query', { accountId: client.accountId, filter: { role: 'inbox' } }, '0']]);
|
||||
const inbox = boxes.methodResponses[0][1].ids[0] as string;
|
||||
const done = await client.request([['Email/import', { accountId: client.accountId, emails: { a: { blobId, mailboxIds: { [inbox]: true }, keywords: {}, receivedAt } } }, '0']]);
|
||||
if (!done.methodResponses[0][1].created?.a) throw new Error(`import: ${JSON.stringify(done.methodResponses[0][1])}`);
|
||||
}
|
||||
|
||||
/** How many messages the settings page says are kept. */
|
||||
async function keptMessages(page: Page): Promise<number> {
|
||||
const text = await page.getByTestId('offline-usage').locator('[data-part="mail"] dd').innerText();
|
||||
return Number(text.match(/\(([\d.,]+) /)?.[1].replace(/[.,]/g, '') ?? NaN);
|
||||
}
|
||||
|
||||
async function statusSettled(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 });
|
||||
}
|
||||
|
||||
test('a cap from the organisation prunes kept mail at the next policy fetch, and lifting it fetches the mail back', async ({ browser }) => {
|
||||
const old = `Old message ${RUN}`;
|
||||
const recent = `Recent message ${RUN}`;
|
||||
await importOld(ENT.alice, old, 200);
|
||||
await deliverAndWait(recent, `Recent body ${RUN}`);
|
||||
const page = await userPage(browser, ENT.alice);
|
||||
await enableOffline(page);
|
||||
const windowRow = page.locator('[data-search-label="How far back to keep"]');
|
||||
await windowRow.locator('select').selectOption('all');
|
||||
await expect.poll(() => keptMessages(page), { timeout: 120_000 }).toBeGreaterThan(0);
|
||||
await statusSettled(page);
|
||||
await expect(async () => {
|
||||
await page.getByTestId('offline-status').waitFor();
|
||||
const client = await jmapAs(ENT.alice);
|
||||
const total = await client.request([['Email/query', { accountId: client.accountId, calculateTotal: true, limit: 0 }, '0']]);
|
||||
expect(await keptMessages(page)).toBe(total.methodResponses[0][1].total);
|
||||
}).toPass({ timeout: 180_000, intervals: [2000] });
|
||||
const before = await keptMessages(page);
|
||||
|
||||
await setPolicy(browser, { organisation: { devices: { offlineMaxDays: 30, noOfflineAttachments: true } } });
|
||||
await page.reload();
|
||||
await expect(windowRow.locator('select')).toHaveValue('30', { timeout: 60_000 });
|
||||
await expect(windowRow.locator('select option')).toHaveText(['30 days']);
|
||||
await expect(windowRow.getByText('Set by your organisation')).toBeVisible();
|
||||
const attachments = page.getByTestId('offline-attachments');
|
||||
await expect(attachments).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(page.locator('[data-search-label="Keep attachments"]').getByText('Set by your organisation')).toBeVisible();
|
||||
await expect.poll(() => keptMessages(page), { timeout: 60_000 }).toBeLessThan(before);
|
||||
await windowRow.scrollIntoViewIfNeeded();
|
||||
await shot(page, 'do-storage-page-capped');
|
||||
|
||||
// Offline, the old message isn't there either: it left the device, not only the list.
|
||||
await toMail(page);
|
||||
await blockMailServer(page.context());
|
||||
await waitUntilOffline(page);
|
||||
const search = async (term: string) => {
|
||||
const box = page.getByPlaceholder('Search mail').first();
|
||||
await box.fill(term);
|
||||
await box.press('Enter');
|
||||
};
|
||||
const rows = page.locator('[data-testid="email-list-item"]');
|
||||
// A word of a message that stayed finds it, so the search does work here.
|
||||
await search(recent);
|
||||
await expect(rows.filter({ hasText: recent }).first()).toBeVisible({ timeout: 30_000 });
|
||||
await search(old);
|
||||
await expect(rows.filter({ hasText: recent })).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(rows.filter({ hasText: old })).toHaveCount(0);
|
||||
await page.context().unroute(`${new URL(JMAP_URL).origin}/**`);
|
||||
|
||||
await setPolicy(browser, {});
|
||||
await page.goto('/settings/backup/offline');
|
||||
await expect(windowRow.locator('select')).toHaveValue('all', { timeout: 60_000 });
|
||||
await expect.poll(() => keptMessages(page), { timeout: 180_000, intervals: [2000] }).toBe(before);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('the storage page: usage, choices, clearing an account', async ({ browser }) => {
|
||||
const subject = `Cleared and fetched again ${RUN}`;
|
||||
await deliverAndWait(subject, `Body ${RUN}`);
|
||||
const page = await userPage(browser, ENT.alice);
|
||||
await enableOffline(page);
|
||||
await expect(page.getByTestId('offline-usage')).toContainText('Messages and search index');
|
||||
await expect.poll(() => keptMessages(page), { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
await shot(page, 'do-storage-page');
|
||||
|
||||
// Keyboard: the choices are native controls in tab order, and the state survives a reload.
|
||||
const select = page.locator('[data-search-label="How far back to keep"] select');
|
||||
await select.focus();
|
||||
await select.selectOption('365');
|
||||
await page.reload();
|
||||
await expect(select).toHaveValue('365', { timeout: 60_000 });
|
||||
await expect(page.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 });
|
||||
|
||||
const before = await page.evaluate(async () => {
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => { const r = indexedDB.open('bulwark-store'); r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); });
|
||||
const all = await new Promise<Array<{ file: string }>>((resolve) => { const r = db.transaction('accounts').objectStore('accounts').getAll(); r.onsuccess = () => resolve(r.result); });
|
||||
db.close();
|
||||
return all.map((record) => record.file);
|
||||
});
|
||||
expect(before).toHaveLength(1);
|
||||
|
||||
await page.getByTestId('offline-clear').click();
|
||||
await expect(page.getByRole('alertdialog').or(page.getByRole('dialog'))).toContainText('Clear this account on this device?');
|
||||
await page.getByRole('button', { name: 'Clear', exact: true }).click();
|
||||
// A new key and file, and the mail comes back by itself, with the window kept.
|
||||
await expect(async () => {
|
||||
const after = await page.evaluate(async () => {
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => { const r = indexedDB.open('bulwark-store'); r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); });
|
||||
const all = await new Promise<Array<{ file: string }>>((resolve) => { const r = db.transaction('accounts').objectStore('accounts').getAll(); r.onsuccess = () => resolve(r.result); });
|
||||
db.close();
|
||||
return all.map((record) => record.file);
|
||||
});
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after).not.toEqual(before);
|
||||
}).toPass({ timeout: 60_000 });
|
||||
await expect.poll(() => keptMessages(page), { timeout: 180_000, intervals: [2000] }).toBeGreaterThan(0);
|
||||
await expect(select).toHaveValue('365');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('the storage page fits at 390 px', async ({ browser }) => {
|
||||
const phone = await userPage(browser, ENT.alice, true);
|
||||
await enableOffline(phone, true);
|
||||
// Scrolling happens in the pane, so the page's own width says nothing: nothing may reach past the screen.
|
||||
const section = phone.locator('[data-settings-section="offline"]');
|
||||
await expect(section).toBeVisible();
|
||||
const past = await section.evaluate((root) => {
|
||||
const width = document.documentElement.clientWidth;
|
||||
const out: string[] = [];
|
||||
for (const el of [root, ...root.querySelectorAll<HTMLElement>('*')]) {
|
||||
const box = el.getBoundingClientRect();
|
||||
if (box.width > 0 && (box.right > width + 1 || box.left < -1)) out.push(`${el.tagName} ${el.getAttribute('data-testid') ?? ''} ${Math.round(box.right)}`);
|
||||
if (el.scrollWidth > el.clientWidth + 1 && getComputedStyle(el).overflowX !== 'visible' && getComputedStyle(el).overflowX !== 'hidden') out.push(`scrolls: ${el.tagName} ${el.className}`);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
expect(past).toEqual([]);
|
||||
await shot(phone, 'do-storage-page-390');
|
||||
await phone.context().close();
|
||||
});
|
||||
|
||||
test('a store the browser deleted is noticed, says what was lost and syncs again', async ({ browser }) => {
|
||||
const subject = `After the eviction ${RUN}`;
|
||||
await deliverAndWait(subject, `Body ${RUN}`);
|
||||
const page = await userPage(browser, ENT.alice);
|
||||
await enableOffline(page);
|
||||
|
||||
// No app on this page, so no worker holds the files, as after closing the tab.
|
||||
await page.goto('/manifest.webmanifest');
|
||||
await expect.poll(() => page.evaluate(async () => {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const raw = localStorage.getItem('bulwark-offline-health');
|
||||
const health = JSON.parse(raw ?? '{}') as Record<string, { pending?: number }>;
|
||||
for (const entry of Object.values(health)) entry.pending = 2;
|
||||
localStorage.setItem('bulwark-offline-health', JSON.stringify(health));
|
||||
return root.removeEntry('.bulwark-store', { recursive: true }).then(() => 'gone', (error: Error) => error.name);
|
||||
}), { timeout: 30_000 }).toBe('gone');
|
||||
|
||||
await page.goto('/settings/backup/offline');
|
||||
await expect(page.getByTestId('offline-evicted')).toContainText('The browser deleted the mail kept on this device', { timeout: 60_000 });
|
||||
await expect(page.getByText("Offline mail on this device couldn't be read", { exact: false }).first()).toBeVisible();
|
||||
await expect(page.getByTestId('offline-evicted')).toContainText('2 unsent messages or changes were lost');
|
||||
await expect.poll(() => keptMessages(page), { timeout: 180_000, intervals: [2000] }).toBeGreaterThan(0);
|
||||
await page.getByTestId('offline-evicted').getByRole('button', { name: 'OK' }).click();
|
||||
await expect(page.getByTestId('offline-evicted')).toHaveCount(0);
|
||||
|
||||
// The key gone and the file still there: nobody can read it, so it goes too.
|
||||
await page.goto('/manifest.webmanifest');
|
||||
await page.evaluate(() => new Promise<void>((resolve, reject) => {
|
||||
const r = indexedDB.open('bulwark-store');
|
||||
r.onerror = () => reject(r.error);
|
||||
r.onsuccess = () => {
|
||||
const tx = r.result.transaction('accounts', 'readwrite');
|
||||
tx.objectStore('accounts').clear();
|
||||
tx.oncomplete = () => { r.result.close(); resolve(); };
|
||||
};
|
||||
}));
|
||||
await page.goto('/settings/backup/offline');
|
||||
await expect(page.getByTestId('offline-evicted')).toBeVisible({ timeout: 60_000 });
|
||||
await expect.poll(() => keptMessages(page), { timeout: 180_000, intervals: [2000] }).toBeGreaterThan(0);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('a browser that refuses to keep the data gets a warning and can be asked again', async ({ browser }) => {
|
||||
const page = await userPage(browser, ENT.alice);
|
||||
await page.context().addInitScript(() => {
|
||||
const answer = () => Promise.resolve(Boolean((window as unknown as { __grant?: boolean }).__grant));
|
||||
StorageManager.prototype.persist = answer;
|
||||
StorageManager.prototype.persisted = answer;
|
||||
});
|
||||
await enableOffline(page);
|
||||
await expect(page.getByTestId('offline-persist-refused')).toContainText('This browser may delete the kept mail');
|
||||
await page.evaluate(() => { (window as unknown as { __grant?: boolean }).__grant = true; });
|
||||
await page.getByTestId('offline-persist-retry').click();
|
||||
await expect(page.getByTestId('offline-persist-refused')).toHaveCount(0);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
/** A message with a text attachment of `size` bytes, repeating `term`, imported so the bytes stay as they are. */
|
||||
async function importWithAttachment(account: string, subject: string, term: string, size: number): Promise<void> {
|
||||
const client = await jmapAs(account);
|
||||
const content = `${term} `.repeat(Math.ceil(size / (term.length + 1)));
|
||||
const boundary = `b${RUN}`;
|
||||
const raw = [
|
||||
`From: ${ENT.bob}`, `To: ${account}`, `Subject: ${subject}`, `Date: ${new Date().toUTCString()}`, `Message-ID: <${RUN}att@example.org>`, 'MIME-Version: 1.0',
|
||||
`Content-Type: multipart/mixed; boundary="${boundary}"`, '', `--${boundary}`, 'Content-Type: text/plain', '', `See the file ${RUN}`, `--${boundary}`,
|
||||
'Content-Type: text/plain; name="kept.txt"', 'Content-Disposition: attachment; filename="kept.txt"', 'Content-Transfer-Encoding: 8bit', '', content, `--${boundary}--`, '',
|
||||
].join('\r\n');
|
||||
const auth = 'Basic ' + Buffer.from(`${account}:${process.env.IT_ENT_PASSWORD}`).toString('base64');
|
||||
const upload = await fetch(`${JMAP_URL}/jmap/upload/${client.accountId}/`, { method: 'POST', headers: { Authorization: auth, 'Content-Type': 'message/rfc822' }, body: raw });
|
||||
if (!upload.ok) throw new Error(`upload: ${upload.status}`);
|
||||
const { blobId } = await upload.json() as { blobId: string };
|
||||
const boxes = await client.request([['Mailbox/query', { accountId: client.accountId, filter: { role: 'inbox' } }, '0']]);
|
||||
const inbox = boxes.methodResponses[0][1].ids[0] as string;
|
||||
const done = await client.request([['Email/import', { accountId: client.accountId, emails: { a: { blobId, mailboxIds: { [inbox]: true }, keywords: {}, receivedAt: new Date().toISOString().replace(/\.\d+Z$/, 'Z') } } }, '0']]);
|
||||
if (!done.methodResponses[0][1].created?.a) throw new Error(`import: ${JSON.stringify(done.methodResponses[0][1])}`);
|
||||
}
|
||||
|
||||
/** How many bytes of attachments the settings page says are kept, roughly: it's what the store counts. */
|
||||
async function keptAttachmentBytes(page: Page): Promise<number> {
|
||||
const row = page.getByTestId('offline-usage').locator('[data-part="attachments"] dd');
|
||||
if ((await row.count()) === 0) return 0;
|
||||
const match = (await row.innerText()).replace(',', '.').match(/([\d.]+)\s*(B|KB|MB|GB)/);
|
||||
return match ? Number(match[1]) * { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 }[match[2] as 'B' | 'KB' | 'MB' | 'GB'] : 0;
|
||||
}
|
||||
|
||||
test('nothing of a kept message is readable on the device', async ({ browser }) => {
|
||||
const subject = `Rawcheck ${RUN} ${RUN}quokka`;
|
||||
const text = `Rawbody ${RUN} ${RUN}wombat`;
|
||||
const inFile = `${RUN}platypus`;
|
||||
await deliverAndWait(subject, text);
|
||||
await importWithAttachment(ENT.alice, `Rawfile ${RUN}`, inFile, 300_000);
|
||||
const page = await userPage(browser, ENT.alice);
|
||||
await toMail(page);
|
||||
await openMessage(page, subject);
|
||||
|
||||
/** Everything this origin has stored, as raw bytes where it's a file, looked through for a term. */
|
||||
const findIn = (term: string) => page.evaluate(async (needle) => {
|
||||
const hits: string[] = [];
|
||||
const encoded = new TextEncoder().encode(needle);
|
||||
const has = (bytes: Uint8Array) => {
|
||||
outer: for (let i = 0; i <= bytes.length - encoded.length; i += 1) {
|
||||
for (let j = 0; j < encoded.length; j += 1) if (bytes[i + j] !== encoded[j]) continue outer;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
let files = 0;
|
||||
let bytesRead = 0;
|
||||
const walk = async (dir: FileSystemDirectoryHandle, path: string) => {
|
||||
for await (const [name, handle] of (dir as unknown as { entries(): AsyncIterable<[string, FileSystemHandle]> }).entries()) {
|
||||
if (handle.kind === 'directory') { await walk(handle as FileSystemDirectoryHandle, `${path}/${name}`); continue; }
|
||||
// Works while the worker holds the handles.
|
||||
const bytes = new Uint8Array(await (await (handle as FileSystemFileHandle).getFile()).arrayBuffer());
|
||||
files += 1;
|
||||
bytesRead += bytes.length;
|
||||
if (has(bytes)) hits.push(`opfs:${path}/${name}`);
|
||||
}
|
||||
};
|
||||
await walk(await navigator.storage.getDirectory(), '');
|
||||
|
||||
const flatten = (value: unknown): string => {
|
||||
if (value instanceof ArrayBuffer) return new TextDecoder('latin1').decode(value);
|
||||
if (ArrayBuffer.isView(value)) return new TextDecoder('latin1').decode(value as Uint8Array);
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value)) return value.map(flatten).join(' ');
|
||||
if (value && typeof value === 'object' && !(value instanceof CryptoKey)) return Object.entries(value).map(([key, inner]) => `${key} ${flatten(inner)}`).join(' ');
|
||||
return String(value);
|
||||
};
|
||||
for (const info of await indexedDB.databases()) {
|
||||
if (!info.name) continue;
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => { const r = indexedDB.open(info.name!); r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); });
|
||||
for (const store of db.objectStoreNames) {
|
||||
const rows = await new Promise<unknown[]>((resolve) => { const r = db.transaction(store).objectStore(store).getAll(); r.onsuccess = () => resolve(r.result); r.onerror = () => resolve([]); });
|
||||
if (flatten(rows).includes(needle)) hits.push(`indexeddb:${info.name}/${store}`);
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
for (const [label, storage] of [['localStorage', localStorage], ['sessionStorage', sessionStorage]] as const) {
|
||||
for (let i = 0; i < storage.length; i += 1) {
|
||||
const key = storage.key(i)!;
|
||||
if (`${key} ${storage.getItem(key)}`.includes(needle)) hits.push(`${label}:${key}`);
|
||||
}
|
||||
}
|
||||
for (const name of await caches.keys()) {
|
||||
const cache = await caches.open(name);
|
||||
for (const request of await cache.keys()) {
|
||||
const response = await cache.match(request);
|
||||
if (response && (await response.clone().text()).includes(needle)) hits.push(`cache:${name}:${request.url}`);
|
||||
}
|
||||
}
|
||||
return { hits, files, bytesRead };
|
||||
}, term);
|
||||
|
||||
// The check can see plaintext: without the store, the boot snapshot has the subject.
|
||||
await expect.poll(async () => (await findIn(subject)).hits, { timeout: 15_000 }).toContain('localStorage:email-snapshot');
|
||||
|
||||
await enableOffline(page);
|
||||
await expect(async () => {
|
||||
const client = await jmapAs(ENT.alice);
|
||||
// The default window: the old message of the cap test stays out.
|
||||
const after = new Date(Date.now() - 90 * 86_400_000).toISOString().replace(/\.\d+Z$/, 'Z');
|
||||
const total = await client.request([['Email/query', { accountId: client.accountId, filter: { after }, calculateTotal: true, limit: 0 }, '0']]);
|
||||
expect(await keptMessages(page)).toBeGreaterThanOrEqual(total.methodResponses[0][1].total);
|
||||
}).toPass({ timeout: 180_000, intervals: [2000] });
|
||||
await toMail(page);
|
||||
await openMessage(page, subject);
|
||||
await expect(body(page)).toContainText(text, { timeout: 20_000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// The file has to be on the device for this to say anything: 300 KB of it.
|
||||
await page.goto('/settings/backup/offline');
|
||||
await expect.poll(() => keptAttachmentBytes(page), { timeout: 180_000, intervals: [2000] }).toBeGreaterThan(300_000);
|
||||
for (const term of [subject, text, `${RUN}quokka`, `${RUN}wombat`, inFile]) {
|
||||
const { hits, files, bytesRead } = await findIn(term);
|
||||
expect(files, 'the store has its files').toBeGreaterThan(0);
|
||||
expect(bytesRead, 'and they hold something').toBeGreaterThan(100_000);
|
||||
expect(hits, `plaintext "${term}"`).toEqual([]);
|
||||
}
|
||||
await page.goto('/mail');
|
||||
await expect(page.locator('[data-testid="email-list-item"]', { hasText: subject }).first()).toBeVisible({ timeout: 60_000 });
|
||||
await page.waitForTimeout(1500);
|
||||
expect((await findIn(subject)).hits).toEqual([]);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('Keep offline in the Files app keeps the file, and the settings list it', async ({ browser }) => {
|
||||
const name = `kept-${RUN}.txt`;
|
||||
const client = await jmapAs(ENT.alice);
|
||||
const auth = 'Basic ' + Buffer.from(`${ENT.alice}:${process.env.IT_ENT_PASSWORD}`).toString('base64');
|
||||
const upload = await fetch(`${JMAP_URL}/jmap/upload/${client.accountId}/`, { method: 'POST', headers: { Authorization: auth, 'Content-Type': 'text/plain' }, body: `Kept file ${RUN}` });
|
||||
expect(upload.ok).toBe(true);
|
||||
const { blobId } = await upload.json() as { blobId: string };
|
||||
const made = await client.request([['FileNode/set', { accountId: client.accountId, create: { f: { name, blobId, parentId: null, type: 'text/plain' } } }, '0']], ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:filenode']);
|
||||
const created = made.methodResponses[0][1].created?.f as { id: string } | undefined;
|
||||
expect(created, JSON.stringify(made.methodResponses[0][1])).toBeTruthy();
|
||||
try {
|
||||
const page = await userPage(browser, ENT.alice);
|
||||
await enableOffline(page);
|
||||
await page.goto('/files');
|
||||
const row = page.getByText(name, { exact: true }).first();
|
||||
await expect(row).toBeVisible({ timeout: 60_000 });
|
||||
await row.click({ button: 'right' });
|
||||
await page.getByRole('menuitem', { name: 'Keep offline' }).click();
|
||||
await page.goto('/settings/backup/offline');
|
||||
await expect(page.getByTestId('offline-kept-file').filter({ hasText: name })).toHaveAttribute('data-kept', 'true', { timeout: 120_000 });
|
||||
await page.getByTestId('offline-kept-files').scrollIntoViewIfNeeded();
|
||||
await shot(page, 'do-storage-page-kept-files');
|
||||
|
||||
await page.goto('/files');
|
||||
await expect(row).toBeVisible({ timeout: 60_000 });
|
||||
await row.click({ button: 'right' });
|
||||
await page.getByRole('menuitem', { name: 'Stop keeping offline' }).click();
|
||||
await page.goto('/settings/backup/offline');
|
||||
await expect(page.getByTestId('offline-account')).toBeVisible({ timeout: 60_000 });
|
||||
await expect(page.getByTestId('offline-kept-file').filter({ hasText: name })).toHaveCount(0, { timeout: 30_000 });
|
||||
await page.context().close();
|
||||
} finally {
|
||||
await client.request([['FileNode/set', { accountId: client.accountId, destroy: [created!.id] }, '0']], ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:filenode']).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test('the admin page saves the caps and no longer promises 30 days or 250 MB', async ({ browser }) => {
|
||||
const admin = await adminContext(browser);
|
||||
const page = await admin.newPage();
|
||||
await page.goto('/admin/org-devices');
|
||||
const days = page.getByTestId('org-devices-offlineMaxDays');
|
||||
await expect(days).toBeVisible({ timeout: 60_000 });
|
||||
await expect(page.locator('body')).not.toContainText('250 MB');
|
||||
await days.fill('45');
|
||||
await page.getByTestId('org-devices-noOfflineAttachments').click();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).first().click();
|
||||
await expect.poll(async () => ((await getPolicy(admin)).organisation as { devices?: { offlineMaxDays?: number; noOfflineAttachments?: boolean } }).devices, { timeout: 15_000 })
|
||||
.toMatchObject({ offlineMaxDays: 45, noOfflineAttachments: true });
|
||||
await shot(page, 'do-admin-offline-caps');
|
||||
await putPolicy(admin, { ...original, scopes: [] });
|
||||
await admin.close();
|
||||
});
|
||||
|
||||
test('a deploy makes the open tab offer a reload and serves no stale chunks', async ({ browser }) => {
|
||||
const redeploy = process.env.IT_OFFLINE_REDEPLOY;
|
||||
test.skip(!redeploy, 'set IT_OFFLINE_REDEPLOY to the command that deploys a second build (enterprise-rig/scratch/f-redeploy.sh)');
|
||||
|
||||
@@ -89,6 +89,57 @@ describe('which account decides', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('offline caps', () => {
|
||||
const scoped = (id: string, match: object, devices: Record<string, unknown>) => ({ id, label: id, match, policy: { organisation: { devices } } });
|
||||
|
||||
function withPolicy(organisationDevices: Record<string, unknown>, scopes: unknown[]) {
|
||||
usePolicyStore.setState({ rawPolicy: { ...DEFAULT_POLICY, organisation: { devices: organisationDevices }, scopes } as never, loaded: true });
|
||||
usePolicyStore.getState().setPolicySubject(null);
|
||||
usePolicyStore.getState().setPolicySubject(ALICE);
|
||||
}
|
||||
|
||||
it('stay numbers across signed-in accounts, the lowest one winning', () => {
|
||||
withPolicy({}, [
|
||||
scoped('a', { groups: ['team@example.org'] }, { offlineMaxDays: 90, offlineMaxTotalMb: 500 }),
|
||||
]);
|
||||
const strictest = getStrictestDevices();
|
||||
expect(strictest.offlineMaxDays).toBe(90);
|
||||
expect(strictest.offlineMaxTotalMb).toBe(500);
|
||||
// Carol has no cap: 0 means none, and must not read as the lowest.
|
||||
expect(devicesForAccount('acc-c').offlineMaxDays).toBe(0);
|
||||
});
|
||||
|
||||
it('a scope can lower a cap but not raise it', () => {
|
||||
withPolicy({ offlineMaxDays: 60, offlineMaxAttachmentMb: 10 }, [
|
||||
scoped('up', { groups: ['team@example.org'] }, { offlineMaxDays: 365, offlineMaxAttachmentMb: 0 }),
|
||||
]);
|
||||
expect(devicesForAccount('acc-a')).toMatchObject({ offlineMaxDays: 60, offlineMaxAttachmentMb: 10 });
|
||||
withPolicy({ offlineMaxDays: 60 }, [
|
||||
scoped('down', { groups: ['team@example.org'] }, { offlineMaxDays: 7 }),
|
||||
]);
|
||||
expect(devicesForAccount('acc-a').offlineMaxDays).toBe(7);
|
||||
});
|
||||
|
||||
it('a scope cannot allow attachments that another one forbids', () => {
|
||||
withPolicy({ noOfflineAttachments: true }, [
|
||||
scoped('up', { groups: ['team@example.org'] }, { noOfflineAttachments: false }),
|
||||
]);
|
||||
expect(devicesForAccount('acc-a').noOfflineAttachments).toBe(true);
|
||||
});
|
||||
|
||||
it('noOffline stays last value wins, as before', () => {
|
||||
withPolicy({ noOffline: true }, [
|
||||
scoped('allow', { groups: ['team@example.org'] }, { noOffline: false }),
|
||||
]);
|
||||
expect(devicesForAccount('acc-a').noOffline).toBe(false);
|
||||
});
|
||||
|
||||
it('drops what is not a number, and negative caps mean none', () => {
|
||||
withPolicy({ offlineMaxDays: 'lots', offlineMaxTotalMb: -5 }, []);
|
||||
expect(devicesForAccount('acc-a')).toMatchObject({ offlineMaxDays: 0, offlineMaxTotalMb: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the backstop', () => {
|
||||
it('refuses a download, says so, and never builds the anchor', () => {
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
|
||||
@@ -196,6 +196,17 @@ describe('api.host.features.keystore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('api.host.features.blockExternalContent', () => {
|
||||
it('is there for a plugin that takes over rendering', () => {
|
||||
expect(snapshotHostFeatures(plugin({ permissions: ['email:render-takeover'] })).blockExternalContent).toBe(true);
|
||||
});
|
||||
|
||||
it('is absent for any other, and without a plugin', () => {
|
||||
expect(snapshotHostFeatures(plugin({ permissions: ['email:read'] }))).not.toHaveProperty('blockExternalContent');
|
||||
expect(snapshotHostFeatures()).not.toHaveProperty('blockExternalContent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the manifest', () => {
|
||||
const BASE = { id: 'keystore-test', name: 'P', version: '1.0.0', author: 'a', description: 'd', type: 'hook' };
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('purgeSignedOutData', () => {
|
||||
for (const key of [
|
||||
'email-snapshot', 'pro-tabs', 'search-history-storage', 'calendar-storage',
|
||||
'files-favorites', 'files-recent-files', 'files-path-stack', 'bulwark-delegate-names',
|
||||
'bulwark-offline-accounts', 'bulwark-offline-scope', 'bulwark-offline-health',
|
||||
'settings-storage', 'theme-storage', 'locale-storage', 'template-storage', 'files-view-mode',
|
||||
]) localStorage.setItem(key, 'x');
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_POLICY, type SettingsPolicy } from '../types';
|
||||
import { ALL_DEVICE_LIMITS, strictestOfAllScopes } from '../server-device-policy';
|
||||
|
||||
// The offline caps are numbers in a section that used to hold only booleans.
|
||||
|
||||
const scope = (id: string, devices: Record<string, unknown>) => ({ id, label: id, match: { domains: [`${id}.example.org`] }, policy: { organisation: { devices } } });
|
||||
|
||||
describe('strictest of all scopes', () => {
|
||||
it('keeps the lowest cap, and a boolean switch still turns on', () => {
|
||||
const policy = {
|
||||
...DEFAULT_POLICY,
|
||||
organisation: { devices: { offlineMaxDays: 90 } },
|
||||
scopes: [scope('a', { offlineMaxDays: 30, offlineMaxTotalMb: 500 }), scope('b', { offlineMaxDays: 60, noPrint: true })],
|
||||
} as unknown as SettingsPolicy;
|
||||
expect(strictestOfAllScopes(policy)).toMatchObject({ offlineMaxDays: 30, offlineMaxTotalMb: 500, offlineMaxAttachmentMb: 0, noPrint: true, noExport: false });
|
||||
});
|
||||
|
||||
it('a scope saying 0 does not lift a cap', () => {
|
||||
const policy = { ...DEFAULT_POLICY, organisation: { devices: { offlineMaxDays: 90 } }, scopes: [scope('a', { offlineMaxDays: 0 })] } as unknown as SettingsPolicy;
|
||||
expect(strictestOfAllScopes(policy).offlineMaxDays).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe('everything on', () => {
|
||||
it('holds booleans as true and caps as the lowest number', () => {
|
||||
expect(ALL_DEVICE_LIMITS).toMatchObject({ noOffline: true, noDownloads: true, noOfflineAttachments: true, offlineMaxDays: 1, offlineMaxAttachmentMb: 1, offlineMaxTotalMb: 1 });
|
||||
});
|
||||
});
|
||||
@@ -168,6 +168,13 @@ export interface OrganisationPolicy {
|
||||
pushWithoutContent: boolean;
|
||||
watermark: boolean;
|
||||
noOffline: boolean;
|
||||
/** How far back a device keeps mail, in days. 0 is no cap. Like the other caps, a scope can only lower it. */
|
||||
offlineMaxDays: number;
|
||||
noOfflineAttachments: boolean;
|
||||
/** The largest attachment or file a device keeps, in MB. 0 is no cap. */
|
||||
offlineMaxAttachmentMb: number;
|
||||
/** All attachments and files of one account on a device, in MB. 0 is no cap. */
|
||||
offlineMaxTotalMb: number;
|
||||
};
|
||||
calendars: {
|
||||
/** Adds up across scopes by id. */
|
||||
@@ -192,6 +199,7 @@ export const DEFAULT_ORGANISATION_POLICY: OrganisationPolicy = {
|
||||
devices: {
|
||||
noDownloads: false, noExport: false, noPrint: false, noInstallPrompt: false,
|
||||
pushWithoutContent: false, watermark: false, noOffline: false,
|
||||
offlineMaxDays: 0, noOfflineAttachments: false, offlineMaxAttachmentMb: 0, offlineMaxTotalMb: 0,
|
||||
},
|
||||
calendars: { feeds: [], freeBusyGroup: '' },
|
||||
};
|
||||
@@ -208,6 +216,8 @@ export type DeepPartial<T> = {
|
||||
const MAX_TEXT = 64 * 1024;
|
||||
const MAX_LIBRARY = 200;
|
||||
const MAX_DOMAINS = 500;
|
||||
const MAX_OFFLINE_DAYS = 3650;
|
||||
const MAX_OFFLINE_MB = 1024 * 1024;
|
||||
// Same shape as the branding host rule, without the wildcard: a bare domain.
|
||||
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
||||
|
||||
@@ -530,6 +540,10 @@ export function sanitizeOrganisationPartial(raw: unknown): DeepPartial<Organisat
|
||||
pushWithoutContent: bool(dev.pushWithoutContent),
|
||||
watermark: bool(dev.watermark),
|
||||
noOffline: bool(dev.noOffline),
|
||||
offlineMaxDays: count(dev.offlineMaxDays, MAX_OFFLINE_DAYS),
|
||||
noOfflineAttachments: bool(dev.noOfflineAttachments),
|
||||
offlineMaxAttachmentMb: count(dev.offlineMaxAttachmentMb, MAX_OFFLINE_MB),
|
||||
offlineMaxTotalMb: count(dev.offlineMaxTotalMb, MAX_OFFLINE_MB),
|
||||
});
|
||||
|
||||
const cal = section('calendars');
|
||||
@@ -538,6 +552,42 @@ export function sanitizeOrganisationPartial(raw: unknown): DeepPartial<Organisat
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The smaller of two caps, where 0 is no cap. */
|
||||
export function tighterCap(a: number, b: number): number {
|
||||
if (!(a > 0)) return b > 0 ? b : 0;
|
||||
return b > 0 ? Math.min(a, b) : a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device keys where the strictest value wins across scopes instead of the
|
||||
* last one: a group scope must not raise what a domain scope lowered.
|
||||
* noOffline isn't one: a scope may allow it where Everyone forbids it.
|
||||
*/
|
||||
const STRICTEST_DEVICE_MERGE: Partial<Record<keyof OrganisationPolicy['devices'], (a: never, b: never) => unknown>> = {
|
||||
offlineMaxDays: tighterCap,
|
||||
offlineMaxAttachmentMb: tighterCap,
|
||||
offlineMaxTotalMb: tighterCap,
|
||||
noOfflineAttachments: (a: boolean, b: boolean) => a || b,
|
||||
};
|
||||
|
||||
/**
|
||||
* Every limit either side sets: for a page that isn't about one account, or a
|
||||
* login whose scopes can't be worked out. Numbers are caps, so a key may never
|
||||
* become `true` here.
|
||||
*/
|
||||
export function strictestDevices(
|
||||
base: OrganisationPolicy['devices'],
|
||||
other: Partial<OrganisationPolicy['devices']>,
|
||||
): OrganisationPolicy['devices'] {
|
||||
const out = { ...base } as Record<string, boolean | number>;
|
||||
for (const [key, value] of Object.entries(other)) {
|
||||
const current = out[key];
|
||||
if (typeof current === 'number') out[key] = tighterCap(current, typeof value === 'number' ? value : 0);
|
||||
else if (value === true) out[key] = true;
|
||||
}
|
||||
return out as OrganisationPolicy['devices'];
|
||||
}
|
||||
|
||||
/** Arrays that add up across scopes by id instead of replacing. */
|
||||
const ADDITIVE_LISTS: Record<string, string> = { templates: 'library', vacationTemplates: 'library', calendars: 'feeds' };
|
||||
|
||||
@@ -553,10 +603,10 @@ function normaliseMerged(policy: OrganisationPolicy): OrganisationPolicy {
|
||||
|
||||
/**
|
||||
* Merges `override` into `base` per leaf of plain objects; arrays and values
|
||||
* replace. Exceptions: the lists in ADDITIVE_LISTS add up by id, and the
|
||||
* sections in WHOLE_SECTIONS are replaced as a whole (a scope's own label
|
||||
* scheme or announcement never mixes with the inherited one), with the
|
||||
* built-in defaults for keys the override leaves out.
|
||||
* replace. Exceptions: the lists in ADDITIVE_LISTS add up by id, the sections
|
||||
* in WHOLE_SECTIONS are replaced as a whole (a scope's own label scheme or
|
||||
* announcement never mixes with the inherited one), with the built-in defaults
|
||||
* for keys the override leaves out, and the offline caps keep the strictest.
|
||||
*/
|
||||
export function mergeOrganisation(
|
||||
base: OrganisationPolicy,
|
||||
@@ -577,6 +627,9 @@ export function mergeOrganisation(
|
||||
const byId = new Map((merged[key] as Array<{ id: string }>).map((t) => [t.id, t]));
|
||||
for (const item of value as Array<{ id: string }>) byId.set(item.id, item);
|
||||
merged[key] = [...byId.values()];
|
||||
} else if (sectionKey === 'devices' && STRICTEST_DEVICE_MERGE[key as keyof OrganisationPolicy['devices']]) {
|
||||
const pick = STRICTEST_DEVICE_MERGE[key as keyof OrganisationPolicy['devices']] as (a: unknown, b: unknown) => unknown;
|
||||
merged[key] = pick(merged[key], value);
|
||||
} else {
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { configManager } from './config-manager';
|
||||
import { DEFAULT_ORGANISATION_POLICY, type OrganisationPolicy } from './organisation-policy';
|
||||
import { DEFAULT_ORGANISATION_POLICY, strictestDevices, type OrganisationPolicy } from './organisation-policy';
|
||||
import { resolvePolicy, type PolicySubject } from './policy-scopes';
|
||||
import type { SettingsPolicy } from './types';
|
||||
import { fetchJmapSession } from '@/lib/stalwart/jmap-api';
|
||||
@@ -75,20 +75,19 @@ export function strictDeviceSubject(creds: DeviceCreds): Promise<PolicySubject |
|
||||
return subject;
|
||||
}
|
||||
|
||||
/** Every switch that Everyone or any scope turns on: for a login whose scopes can't be worked out. */
|
||||
/** Every switch that Everyone or any scope turns on, and the lowest of each cap: for a login whose scopes can't be worked out. */
|
||||
export function strictestOfAllScopes(policy: SettingsPolicy): ServerDevicePolicy {
|
||||
const out: ServerDevicePolicy = { ...DEFAULT_ORGANISATION_POLICY.devices, ...(resolvePolicy(policy, null).policy.organisation?.devices ?? {}) };
|
||||
let out: ServerDevicePolicy = { ...DEFAULT_ORGANISATION_POLICY.devices, ...(resolvePolicy(policy, null).policy.organisation?.devices ?? {}) };
|
||||
for (const scope of policy.scopes ?? []) {
|
||||
const devices = scope.policy?.organisation?.devices as Partial<ServerDevicePolicy> | undefined;
|
||||
if (!devices) continue;
|
||||
for (const key of Object.keys(out) as (keyof ServerDevicePolicy)[]) if (devices[key] === true) out[key] = true;
|
||||
if (devices) out = strictestDevices(out, devices);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Everything on: when not even the policy can be read. */
|
||||
/** Everything on, and each cap as low as it goes: when not even the policy can be read. */
|
||||
export const ALL_DEVICE_LIMITS: ServerDevicePolicy = Object.fromEntries(
|
||||
Object.keys(DEFAULT_ORGANISATION_POLICY.devices).map((key) => [key, true]),
|
||||
Object.entries(DEFAULT_ORGANISATION_POLICY.devices).map(([key, off]) => [key, typeof off === 'number' ? 1 : true]),
|
||||
) as ServerDevicePolicy;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getDesktopBridge } from './bridge';
|
||||
|
||||
/**
|
||||
* Takes back what the user allowed in this session's certificate dialogs: the next
|
||||
* account at this computer gets asked. A refusal stays (it lasts until the app quits),
|
||||
* and so does a stored yes for an instance, which is about the instance and not the
|
||||
* person. Nothing happens in a browser or on a shell without the call.
|
||||
*/
|
||||
export function clearKeystoreGrants(): void {
|
||||
void Promise.resolve(getDesktopBridge()?.keystore?.clearGrants?.()).catch(() => {});
|
||||
}
|
||||
@@ -180,6 +180,8 @@ export interface DesktopKeystoreBridge {
|
||||
accessState: () => Promise<DesktopKeystoreAccessState>;
|
||||
/** Takes back a stored yes and this session's grants. Only ever narrows. */
|
||||
revokeAccess: () => Promise<void>;
|
||||
/** Takes back this session's grants, for the next account. Absent on older shells. */
|
||||
clearGrants?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface BulwarkDesktopBridge {
|
||||
|
||||
+8
-10
@@ -26,7 +26,7 @@
|
||||
* the export engine and previews read blobs too.
|
||||
*/
|
||||
|
||||
import { DEFAULT_ORGANISATION_POLICY, type OrganisationPolicy } from '@/lib/admin/organisation-policy';
|
||||
import { DEFAULT_ORGANISATION_POLICY, strictestDevices, type OrganisationPolicy } from '@/lib/admin/organisation-policy';
|
||||
import { setDownloadGuard } from '@/lib/download-guard';
|
||||
import { subjectForAccount } from '@/lib/org/account-subjects';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
@@ -37,8 +37,6 @@ export type DevicePolicy = OrganisationPolicy['devices'];
|
||||
|
||||
export const NO_DEVICE_LIMITS: DevicePolicy = DEFAULT_ORGANISATION_POLICY.devices;
|
||||
|
||||
const KEYS = Object.keys(NO_DEVICE_LIMITS) as (keyof DevicePolicy)[];
|
||||
|
||||
function devicesOf(policy: { organisation?: OrganisationPolicy } | null | undefined): DevicePolicy {
|
||||
return { ...NO_DEVICE_LIMITS, ...(policy?.organisation?.devices ?? {}) };
|
||||
}
|
||||
@@ -86,6 +84,11 @@ export function devicesForAccount(subject: DeviceSubject): DevicePolicy {
|
||||
return devicesOf(policy);
|
||||
}
|
||||
|
||||
/** What applies to anyone, with no account's groups: for data of an account nobody here is signed in to. */
|
||||
export function devicesForNoOne(): DevicePolicy {
|
||||
return devicesOf(usePolicyStore.getState().resolveFor(null));
|
||||
}
|
||||
|
||||
/** Local ids of every account signed in here, the active one included. */
|
||||
export function signedInAccountIds(): string[] {
|
||||
const auth = useAuthStore.getState();
|
||||
@@ -99,14 +102,9 @@ export function signedInAccountIds(): string[] {
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/** Every switch that any signed-in account has on: for pages that aren't about one account. */
|
||||
/** Every switch that any signed-in account has on, and the lowest of each cap: for pages that aren't about one account. */
|
||||
export function getStrictestDevices(): DevicePolicy {
|
||||
const out: DevicePolicy = { ...devicesForAccount(null) };
|
||||
for (const id of signedInAccountIds()) {
|
||||
const devices = devicesForAccount(id);
|
||||
for (const key of KEYS) if (devices[key]) out[key] = true;
|
||||
}
|
||||
return out;
|
||||
return signedInAccountIds().reduce((out, id) => strictestDevices(out, devicesForAccount(id)), devicesForAccount(null));
|
||||
}
|
||||
|
||||
/** The switches that keep mail in: a copy may only go where they are at least as strict. */
|
||||
|
||||
+35
-8
@@ -24,6 +24,7 @@ import { isTaskLikeObject, type ScannedCalendarObject } from "@/lib/calendar-com
|
||||
import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectionName } from "@/lib/webdav/calendar-collection";
|
||||
import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
|
||||
import { decodeFileNodeName } from "./filenode-name";
|
||||
import { PartialAccountsError } from "./partial-accounts";
|
||||
import { fileNodeKind, fromDraftFileNode, toDraftFileNodeCreate } from "./filenode-kind";
|
||||
import { fileNodeDepth } from "./filenode-depth";
|
||||
import { contactFromWire, contactToWire } from "./contact-wire";
|
||||
@@ -7577,10 +7578,16 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws PartialAccountsError when an account couldn't answer, with what the
|
||||
* others gave, so a failure doesn't read as that account having no books.
|
||||
*/
|
||||
async getAllAddressBooks(): Promise<AddressBook[]> {
|
||||
const failed: string[] = [];
|
||||
let failure: unknown;
|
||||
const allBooks: AddressBook[] = [];
|
||||
const primaryId = this.getContactsAccountId();
|
||||
try {
|
||||
const allBooks: AddressBook[] = [];
|
||||
const primaryId = this.getContactsAccountId();
|
||||
const accountIds = this.getContactCapableAccountIds();
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
@@ -7603,17 +7610,23 @@ export class JMAPClient implements IJMAPClient {
|
||||
isShared: !isPrimary,
|
||||
}));
|
||||
allBooks.push(...books);
|
||||
} else if (response.methodResponses?.[0]?.[0] === "error") {
|
||||
const refused = response.methodResponses[0][1];
|
||||
throw new Error(refused?.description || refused?.type || "AddressBook/get failed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch address books for account ${accountId}:`, error);
|
||||
failed.push(accountId);
|
||||
failure ??= error;
|
||||
}
|
||||
}
|
||||
|
||||
return allBooks;
|
||||
if (failed.length === 0) return allBooks;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch all address books:', error);
|
||||
return this.getAddressBooks();
|
||||
}
|
||||
throw new PartialAccountsError(allBooks, failed, primaryId, failure);
|
||||
}
|
||||
|
||||
async createAddressBook(name: string): Promise<AddressBook> {
|
||||
@@ -8480,6 +8493,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
], this.contactUsing());
|
||||
|
||||
const queryResult = response.methodResponses?.[0];
|
||||
// A refused call isn't an empty address book: the caller keeps what it has.
|
||||
if (queryResult?.[0] === "error") throw new Error(queryResult[1]?.description || queryResult[1]?.type || "ContactCard/query failed");
|
||||
if (queryResult?.[0] !== "ContactCard/query") break;
|
||||
|
||||
const ids: string[] = queryResult[1].ids || [];
|
||||
@@ -8502,8 +8517,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
["ContactCard/get", { accountId, ids: chunk }, "g"],
|
||||
], this.contactUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "ContactCard/get") {
|
||||
const list = (response.methodResponses[0][1].list || []) as ContactCard[];
|
||||
const getResult = response.methodResponses?.[0];
|
||||
if (getResult?.[0] === "error") throw new Error(getResult[1]?.description || getResult[1]?.type || "ContactCard/get failed");
|
||||
if (getResult?.[0] === "ContactCard/get") {
|
||||
const list = (getResult[1].list || []) as ContactCard[];
|
||||
allContacts.push(...list.map(contactFromWire));
|
||||
}
|
||||
}
|
||||
@@ -8523,10 +8540,17 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws PartialAccountsError when an account couldn't answer, with what the
|
||||
* others gave, so a failure doesn't read as that account having no cards.
|
||||
*/
|
||||
async getAllContacts(): Promise<ContactCard[]> {
|
||||
const failed: string[] = [];
|
||||
let failure: unknown;
|
||||
let allContacts: ContactCard[] = [];
|
||||
const primaryId = this.getContactsAccountId();
|
||||
try {
|
||||
const allContacts: ContactCard[] = [];
|
||||
const primaryId = this.getContactsAccountId();
|
||||
allContacts = [];
|
||||
const accountIds = this.getContactCapableAccountIds();
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
@@ -8549,14 +8573,17 @@ export class JMAPClient implements IJMAPClient {
|
||||
allContacts.push(...contacts);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch contacts for account ${accountId}:`, error);
|
||||
failed.push(accountId);
|
||||
failure ??= error;
|
||||
}
|
||||
}
|
||||
|
||||
return allContacts;
|
||||
if (failed.length === 0) return allContacts;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch all contacts:', error);
|
||||
return this.getContacts();
|
||||
}
|
||||
throw new PartialAccountsError(allContacts, failed, primaryId, failure);
|
||||
}
|
||||
|
||||
async getContact(contactId: string, accountId?: string): Promise<ContactCard | null> {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* A listing across JMAP accounts where at least one account couldn't answer.
|
||||
* It carries what the others gave. An account that failed isn't an account
|
||||
* with nothing in it, so the caller keeps what it had for that one.
|
||||
*/
|
||||
export class PartialAccountsError<T> extends Error {
|
||||
constructor(
|
||||
readonly items: T[],
|
||||
readonly failedAccountIds: string[],
|
||||
readonly primaryAccountId: string,
|
||||
cause: unknown,
|
||||
) {
|
||||
super(cause instanceof Error ? cause.message : 'An account could not answer');
|
||||
this.name = 'PartialAccountsError';
|
||||
}
|
||||
}
|
||||
|
||||
/** By name: `instanceof` fails across Next's bundle copies. */
|
||||
export function partialAccounts<T>(error: unknown): PartialAccountsError<T> | null {
|
||||
return error instanceof Error && error.name === 'PartialAccountsError' ? error as PartialAccountsError<T> : null;
|
||||
}
|
||||
|
||||
/** What to show after a partial listing: the answers, and what's held for the accounts that failed. */
|
||||
export function withFailedAccountsKept<T extends { accountId?: string }>(partial: PartialAccountsError<T>, held: readonly T[]): T[] {
|
||||
const failed = new Set(partial.failedAccountIds);
|
||||
return [...partial.items, ...held.filter((item) => failed.has(item.accountId ?? partial.primaryAccountId))];
|
||||
}
|
||||
@@ -43,7 +43,7 @@ beforeEach(async () => {
|
||||
forgetLegacyMove();
|
||||
localStorage.clear();
|
||||
useOfflineStore.setState({ enabled: [], downServers: [], browserOffline: false, outbox: [], pendingChanges: 0, conflicts: [] });
|
||||
usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
|
||||
usePolicyStore.setState({ loaded: true, subject: { email: 'alice@example.org', groups: [] }, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
|
||||
useAccountStore.setState({ accounts: [{ id: 'acc', serverUrl: SERVER, username: 'alice@example.org' } as never] });
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (init?.mode === 'no-cors') {
|
||||
|
||||
@@ -67,7 +67,7 @@ beforeEach(async () => {
|
||||
forgetOrphanCheck();
|
||||
localStorage.clear();
|
||||
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, conflicts: [], localDrafts: [], browserOffline: false, downServers: [], startedOffline: false });
|
||||
usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
|
||||
usePolicyStore.setState({ loaded: true, subject: { email: 'alice@example.org', groups: [] }, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
|
||||
useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
|
||||
const client = fakeClient();
|
||||
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { getOfflineHooks, setOfflineHooks } from '@/lib/jmap/offline-hooks';
|
||||
import { setStoreBackend } from '@/lib/store/client/api';
|
||||
import { FakeJmap } from '@/lib/store/__tests__/helpers/fake-jmap';
|
||||
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import {
|
||||
chooseScope,
|
||||
clearAccount,
|
||||
disableOffline,
|
||||
enableOffline,
|
||||
enforcePolicy,
|
||||
refreshQueueState,
|
||||
refreshUsage,
|
||||
registerOfflineHooks,
|
||||
scopeForAccount,
|
||||
setOfflineNotifier,
|
||||
startOfflineMode,
|
||||
unsentCount,
|
||||
wipeEverything,
|
||||
type OfflineNotice,
|
||||
} from '../offline-mode';
|
||||
import { clearEvicted, markEvicted, OFFLINE_HEALTH_KEY, useOfflineStore, writeEnabled, writeHealth } from '../state';
|
||||
|
||||
const NOW = Date.now();
|
||||
const iso = (daysAgo: number) => new Date(NOW - daysAgo * 86_400_000).toISOString();
|
||||
const notices: OfflineNotice[] = [];
|
||||
let store: InProcessStore;
|
||||
let server: FakeJmap;
|
||||
|
||||
function setDevices(devices: Partial<typeof DEFAULT_ORGANISATION_POLICY.devices>): void {
|
||||
usePolicyStore.setState({
|
||||
loaded: true, source: 'server', subject: { email: 'alice@example.org', groups: [] }, subjectPending: false,
|
||||
policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } },
|
||||
} as never);
|
||||
}
|
||||
|
||||
const ids = (account = 'acc') => store.dbs.get(account)!.all<{ id: string }>('SELECT id FROM email ORDER BY id').map((row) => row.id);
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
server = new FakeJmap();
|
||||
store = new InProcessStore(server);
|
||||
setStoreBackend(store);
|
||||
notices.length = 0;
|
||||
setOfflineNotifier((notice) => notices.push(notice));
|
||||
useOfflineStore.setState({ enabled: [], choices: {}, health: {}, outbox: [], conflicts: [], areaConflicts: [], usage: {}, storageFull: {}, progress: {}, persist: 'unknown', browserOffline: false, downServers: [], startedOffline: false });
|
||||
useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
|
||||
useAuthStore.setState({ activeAccountId: 'acc', client: null, getClientForAccount: (() => null) as never, getAllConnectedClients: (() => new Map()) as never });
|
||||
setDevices({});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
setOfflineHooks(null);
|
||||
setOfflineNotifier(() => {});
|
||||
await wipeEverything();
|
||||
setStoreBackend(null);
|
||||
});
|
||||
|
||||
describe('at page load, before the subject is known', () => {
|
||||
const forbiddenExceptGroup = {
|
||||
...usePolicyStore.getState().rawPolicy,
|
||||
organisation: { devices: { noOffline: true } },
|
||||
scopes: [{ id: 'allowed', label: 'Allowed', match: { groups: ['team@example.org'] }, policy: { organisation: { devices: { noOffline: false } } } }],
|
||||
};
|
||||
|
||||
function loading(): void {
|
||||
useOfflineStore.setState({ enabled: ['acc'] });
|
||||
usePolicyStore.setState({ rawPolicy: forbiddenExceptGroup, loaded: true, source: 'server', partial: false, subjectPending: false, subject: null } as never);
|
||||
usePolicyStore.setState({ policy: usePolicyStore.getState().resolveFor(null) } as never);
|
||||
useAuthStore.setState({ activeAccountId: 'acc', isLoading: true, client: null });
|
||||
}
|
||||
|
||||
it('Everyone forbidding it wipes nothing while a group scope may allow it', async () => {
|
||||
loading();
|
||||
enforcePolicy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(store.removed).toEqual([]);
|
||||
expect(useOfflineStore.getState().enabled).toEqual(['acc']);
|
||||
expect(notices.filter((n) => n.kind === 'forbidden')).toEqual([]);
|
||||
});
|
||||
|
||||
it('with the subject in, the group decides', async () => {
|
||||
loading();
|
||||
usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: ['team@example.org'] });
|
||||
enforcePolicy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(useOfflineStore.getState().enabled).toEqual(['acc']);
|
||||
|
||||
usePolicyStore.getState().setPolicySubject({ email: 'bob@example.org', groups: [] });
|
||||
enforcePolicy();
|
||||
await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual([]));
|
||||
});
|
||||
|
||||
it('with no client coming, Everyone decides', async () => {
|
||||
loading();
|
||||
useAuthStore.setState({ isLoading: false, client: null });
|
||||
enforcePolicy();
|
||||
await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual([]));
|
||||
});
|
||||
|
||||
it('pushes no scope either: a wider one would cost a re-list on every reload', async () => {
|
||||
usePolicyStore.setState({ rawPolicy: { ...forbiddenExceptGroup, organisation: {}, scopes: [] }, loaded: true, source: 'server', partial: false, subject: null } as never);
|
||||
useOfflineStore.setState({ enabled: ['acc'] });
|
||||
useAuthStore.setState({ activeAccountId: 'acc', isLoading: true, client: null });
|
||||
const setScope = vi.spyOn(store, 'setScope');
|
||||
enforcePolicy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(setScope).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the public part of the policy', () => {
|
||||
it('is not the organisation\'s word: no scope goes out, and no desktop default', async () => {
|
||||
useOfflineStore.setState({ enabled: ['acc'] });
|
||||
usePolicyStore.setState({ partial: true } as never);
|
||||
const setScope = vi.spyOn(store, 'setScope');
|
||||
enforcePolicy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(setScope).not.toHaveBeenCalled();
|
||||
|
||||
useOfflineStore.setState({ enabled: [] });
|
||||
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 };
|
||||
enforcePolicy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(useOfflineStore.getState().enabled).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an account dropped by the app', () => {
|
||||
it('is judged by what applies to anyone, not by the groups of whoever is signed in', async () => {
|
||||
const policy = {
|
||||
...usePolicyStore.getState().rawPolicy,
|
||||
organisation: { devices: { noOffline: true } },
|
||||
scopes: [{ id: 'allowed', label: 'Allowed', match: { groups: ['team@example.org'] }, policy: { organisation: { devices: { noOffline: false } } } }],
|
||||
};
|
||||
usePolicyStore.setState({ rawPolicy: policy, loaded: true, source: 'server', partial: false, subjectPending: false, subject: null } as never);
|
||||
useAuthStore.setState({ activeAccountId: 'other', isLoading: false, client: null });
|
||||
useAccountStore.setState({ accounts: [{ id: 'other', label: 'other', serverUrl: 'https://mail.example.org', username: 'other@example.org' } as never] });
|
||||
usePolicyStore.getState().setPolicySubject({ email: 'other@example.org', groups: ['team@example.org'] });
|
||||
useOfflineStore.setState({ enabled: ['gone', 'other'] });
|
||||
markEvicted('gone');
|
||||
enforcePolicy();
|
||||
await vi.waitFor(() => expect(store.removed).toContain('gone'));
|
||||
// Whoever is here keeps theirs: they are in the group.
|
||||
expect(store.removed).not.toContain('other');
|
||||
expect(useOfflineStore.getState().enabled).toEqual(['other']);
|
||||
clearEvicted('gone');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a policy fetch', () => {
|
||||
async function kept(): Promise<void> {
|
||||
server.addEmail({ id: 'old', receivedAt: iso(100) });
|
||||
server.addEmail({ id: 'new', receivedAt: iso(5) });
|
||||
writeEnabled(['acc']);
|
||||
useOfflineStore.setState({ choices: { acc: { on: true, days: null } } });
|
||||
await store.setScope('acc', scopeForAccount('acc').scope);
|
||||
await store.sync('acc');
|
||||
expect(ids()).toEqual(['new', 'old']);
|
||||
}
|
||||
|
||||
it('with a lower cap prunes what is kept, without fetching what stays', async () => {
|
||||
await kept();
|
||||
server.calls.length = 0;
|
||||
setDevices({ offlineMaxDays: 30 });
|
||||
enforcePolicy();
|
||||
await vi.waitFor(() => expect(ids()).toEqual(['new']));
|
||||
expect(server.calls.filter(([name]) => name === 'Email/query')).toEqual([]);
|
||||
});
|
||||
|
||||
it('with the cap raised again starts a backfill', async () => {
|
||||
await kept();
|
||||
setDevices({ offlineMaxDays: 30 });
|
||||
enforcePolicy();
|
||||
await vi.waitFor(() => expect(ids()).toEqual(['new']));
|
||||
setDevices({});
|
||||
enforcePolicy();
|
||||
await vi.waitFor(() => expect(ids()).toEqual(['new', 'old']));
|
||||
});
|
||||
|
||||
it('with defaults only (no policy heard yet) changes nothing', async () => {
|
||||
await kept();
|
||||
usePolicyStore.setState({ source: 'fallback' } as never);
|
||||
const setScope = vi.spyOn(store, 'setScope');
|
||||
enforcePolicy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(setScope).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the user picking what to keep', () => {
|
||||
it('goes to the store and prunes', async () => {
|
||||
server.addEmail({ id: 'old', receivedAt: iso(100) });
|
||||
server.addEmail({ id: 'new', receivedAt: iso(5) });
|
||||
writeEnabled(['acc']);
|
||||
useOfflineStore.setState({ choices: { acc: { on: true, days: null } } });
|
||||
await store.setScope('acc', scopeForAccount('acc').scope);
|
||||
await store.sync('acc');
|
||||
chooseScope('acc', { days: 30 });
|
||||
await vi.waitFor(() => expect(ids()).toEqual(['new']));
|
||||
expect(JSON.parse(localStorage.getItem('bulwark-offline-scope')!).acc.days).toBe(30);
|
||||
});
|
||||
|
||||
it('waits for a choice to settle: arrowing past a short window prunes nothing', async () => {
|
||||
writeEnabled(['acc']);
|
||||
useOfflineStore.setState({ choices: { acc: { on: true, days: null } } });
|
||||
const setScope = vi.spyOn(store, 'setScope');
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
chooseScope('acc', { days: 30 });
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
chooseScope('acc', { days: 90 });
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
chooseScope('acc', { days: 365 });
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(setScope).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect(setScope).toHaveBeenCalledTimes(1);
|
||||
expect(setScope.mock.calls[0][1].days).toBe(365);
|
||||
});
|
||||
|
||||
it('starts from what is in force: someone from before the local store keeps the rest of what they had', () => {
|
||||
writeEnabled(['acc']);
|
||||
useOfflineStore.setState({ choices: {} });
|
||||
chooseScope('acc', { attachments: false });
|
||||
expect(useOfflineStore.getState().choices.acc).toMatchObject({ on: true, days: 30, attachments: false });
|
||||
});
|
||||
|
||||
it('can\'t get past the cap', () => {
|
||||
setDevices({ offlineMaxDays: 30 });
|
||||
writeEnabled(['acc']);
|
||||
chooseScope('acc', { days: null });
|
||||
expect(scopeForAccount('acc').scope.days).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('opened messages', () => {
|
||||
it('past the organisation\'s cap aren\'t recorded; under it they are', async () => {
|
||||
registerOfflineHooks();
|
||||
writeEnabled(['acc']);
|
||||
setDevices({ offlineMaxDays: 30 });
|
||||
const area = vi.spyOn(store, 'area');
|
||||
const client = { getServerUrl: () => 'https://mail.example.org', getUsername: () => 'alice@example.org', getAccountId: () => 'j' } as never;
|
||||
const message = (id: string, receivedAt: string) => ({ id, receivedAt, mailboxIds: { inbox: true }, bodyValues: { 1: { value: 'x', isTruncated: false } } });
|
||||
getOfflineHooks()!.record!(client, [['Email/get', { accountId: 'j' }, 'c']], {
|
||||
methodResponses: [['Email/get', { accountId: 'j', list: [message('old', iso(100)), message('new', iso(5))] }, 'c']],
|
||||
} as never);
|
||||
await vi.waitFor(() => expect(area).toHaveBeenCalled());
|
||||
const recorded = area.mock.calls.filter(([, name]) => name === 'mail.recordOpened').flatMap(([, , params]) => (params as { emails: Array<{ id: string }> }).emails.map((email) => email.id));
|
||||
expect(recorded).toEqual(['new']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the usage of several accounts', () => {
|
||||
it('one store in trouble leaves what the others said', async () => {
|
||||
writeEnabled(['acc', 'bad']);
|
||||
const area = vi.spyOn(store, 'area');
|
||||
const real = area.getMockImplementation();
|
||||
area.mockImplementation(async (account: string, name: string) => {
|
||||
if (name !== 'store.usage') return real ? real(account, name) : undefined;
|
||||
if (account === 'bad') throw new Error('gone');
|
||||
return { bytes: 5, parts: [] };
|
||||
});
|
||||
await refreshUsage();
|
||||
expect(useOfflineStore.getState().usage.acc?.bytes).toBe(5);
|
||||
area.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a browser clearing the store would lose', () => {
|
||||
it('counts local drafts too', async () => {
|
||||
writeEnabled(['acc']);
|
||||
const real = store.area.bind(store);
|
||||
vi.spyOn(store, 'area').mockImplementation(async (account: string, name: string, params?: unknown) => (
|
||||
name.startsWith('drafts.') && !name.includes('Elsewhere')
|
||||
? [{ localId: 'local-1', jmap: 'j', subject: 'Only here', status: 'waiting', createdAt: 0 }]
|
||||
: real(account, name, params)
|
||||
) as never);
|
||||
await refreshQueueState();
|
||||
expect(useOfflineStore.getState().health.acc?.pending).toBe(1);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what would be lost', () => {
|
||||
it('counts changes per account when a store can\'t answer, and local drafts', async () => {
|
||||
useOfflineStore.setState({
|
||||
enabled: ['acc', 'b'],
|
||||
changesBy: { acc: 2, b: 7 },
|
||||
outbox: [],
|
||||
localDrafts: [{ account: 'acc', localId: 'd', status: 'waiting' } as never],
|
||||
});
|
||||
vi.spyOn(store, 'area').mockRejectedValue(new Error('gone'));
|
||||
expect(await unsentCount('acc')).toEqual({ messages: 1, changes: 2 });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('turning the last account off', () => {
|
||||
it('keeps the off of another account still signed in', async () => {
|
||||
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 };
|
||||
useAuthStore.setState({ activeAccountId: 'acc', getAllConnectedClients: (() => new Map([['acc', {}], ['other', {}]])) as never });
|
||||
useOfflineStore.setState({ choices: { other: { on: false } } });
|
||||
writeEnabled(['acc']);
|
||||
await disableOffline('acc');
|
||||
expect(useOfflineStore.getState().choices).toEqual({ acc: { on: false }, other: { on: false } });
|
||||
expect(JSON.parse(localStorage.getItem('bulwark-offline-scope')!).other.on).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearing an account', () => {
|
||||
it('removes its store and starts over, and it stays on', async () => {
|
||||
server.addEmail({ id: 'new', receivedAt: iso(5) });
|
||||
writeEnabled(['acc']);
|
||||
await store.sync('acc');
|
||||
expect(ids()).toEqual(['new']);
|
||||
const before = store.dbs.get('acc');
|
||||
|
||||
await clearAccount('acc');
|
||||
|
||||
expect(store.removed).toContain('acc');
|
||||
expect(useOfflineStore.getState().enabled).toEqual(['acc']);
|
||||
expect(store.dbs.get('acc')).not.toBe(before);
|
||||
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', { clearedAt: 1 });
|
||||
const stop = startOfflineMode();
|
||||
const ensureOpen = vi.spyOn(store, 'ensureOpen');
|
||||
ensureOpen.mockClear();
|
||||
const area = vi.spyOn(store, 'area');
|
||||
// What the start itself asks for is over.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
area.mockClear();
|
||||
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.clearedAt).toBe(2);
|
||||
// Its lists and sizes are of a store that's gone.
|
||||
await vi.waitFor(() => expect(area).toHaveBeenCalledWith('acc', 'store.usage'));
|
||||
stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the desktop app', () => {
|
||||
const asDesktop = () => { (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 }; };
|
||||
|
||||
it('keeps mail for an account without asking, and says so once', async () => {
|
||||
asDesktop();
|
||||
enforcePolicy();
|
||||
await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual(['acc']));
|
||||
expect(notices.filter((n) => n.kind === 'default-on')).toHaveLength(1);
|
||||
enforcePolicy();
|
||||
expect(notices.filter((n) => n.kind === 'default-on')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('not when the organisation forbids it', async () => {
|
||||
asDesktop();
|
||||
setDevices({ noOffline: true });
|
||||
enforcePolicy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(useOfflineStore.getState().enabled).toEqual([]);
|
||||
expect(notices.filter((n) => n.kind === 'default-on')).toEqual([]);
|
||||
});
|
||||
|
||||
it('not before the policy is known', async () => {
|
||||
asDesktop();
|
||||
usePolicyStore.setState({ source: 'none' } as never);
|
||||
enforcePolicy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(useOfflineStore.getState().enabled).toEqual([]);
|
||||
});
|
||||
|
||||
it('not after the user turned it off', async () => {
|
||||
asDesktop();
|
||||
enforcePolicy();
|
||||
await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual(['acc']));
|
||||
await disableOffline('acc');
|
||||
enforcePolicy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(useOfflineStore.getState().enabled).toEqual([]);
|
||||
expect(useOfflineStore.getState().choices.acc?.on).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('a browser', () => {
|
||||
it('stays opt-in', async () => {
|
||||
enforcePolicy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(useOfflineStore.getState().enabled).toEqual([]);
|
||||
expect(notices).toEqual([]);
|
||||
await enableOffline('acc');
|
||||
expect(useOfflineStore.getState().enabled).toEqual(['acc']);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ beforeEach(async () => {
|
||||
setStoreBackend(new InProcessStore());
|
||||
localStorage.clear();
|
||||
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, browserOffline: false, downServers: [], startedOffline: false });
|
||||
usePolicyStore.setState({ loaded: true, subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never);
|
||||
usePolicyStore.setState({ loaded: true, subject: { email: 'alice@example.org', groups: [] }, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never);
|
||||
useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import { setStoreBackend } from '@/lib/store/client/api';
|
||||
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
|
||||
import { forgetLegacyMove, LEGACY_DB_NAME, moveLegacyOutbox } from '../migrate-legacy';
|
||||
import { readQueue } from '../queue';
|
||||
import { useOfflineStore } from '../state';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { effectiveScope, platformDefault, userChoiceFor } from '../scope';
|
||||
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'];
|
||||
@@ -72,7 +74,8 @@ beforeEach(async () => {
|
||||
store = new InProcessStore();
|
||||
setStoreBackend(store);
|
||||
forgetLegacyMove();
|
||||
useOfflineStore.setState({ enabled: ['acc'] });
|
||||
localStorage.clear();
|
||||
useOfflineStore.setState({ enabled: ['acc'], choices: {} });
|
||||
await new Promise<void>((resolve) => {
|
||||
const request = indexedDB.deleteDatabase(LEGACY_DB_NAME);
|
||||
request.onsuccess = () => resolve();
|
||||
@@ -172,6 +175,23 @@ describe('moving the old outbox into the store', () => {
|
||||
expect(await store.area('acc', 'mail.session')).toMatchObject({ apiUrl: 'https://old/jmap' });
|
||||
});
|
||||
|
||||
it('keeps the account on, with the 30 days it had, and writes that down as its choice', async () => {
|
||||
await seedOld();
|
||||
await moveLegacyOutbox();
|
||||
const state = useOfflineStore.getState();
|
||||
expect(state.enabled).toContain('acc');
|
||||
expect(state.choices.acc).toMatchObject({ on: true, days: 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);
|
||||
});
|
||||
|
||||
it('never takes a window the user already picked', async () => {
|
||||
await seedOld();
|
||||
writeChoice('acc', { on: true, days: 365 });
|
||||
await moveLegacyOutbox();
|
||||
expect(useOfflineStore.getState().choices.acc.days).toBe(365);
|
||||
});
|
||||
|
||||
it('never creates the old database when it isn\'t there', async () => {
|
||||
await moveLegacyOutbox();
|
||||
expect(await oldExists()).toBe(false);
|
||||
|
||||
@@ -56,7 +56,7 @@ beforeEach(async () => {
|
||||
forgetLegacyMove();
|
||||
localStorage.clear();
|
||||
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, conflicts: [], browserOffline: false, downServers: [], startedOffline: false });
|
||||
usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
|
||||
usePolicyStore.setState({ loaded: true, subject: { email: 'alice@example.org', groups: [] }, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
|
||||
useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
|
||||
});
|
||||
|
||||
@@ -336,6 +336,8 @@ describe('a send whose response got lost', () => {
|
||||
it('a lookup that can\'t decide leaves it uncertain for the user, who can still send it', async () => {
|
||||
const client = fakeClient();
|
||||
const entry = await uncertainSend(client);
|
||||
// The store's own sync mustn't take the failure meant for the lookup.
|
||||
await store.sync('acc');
|
||||
server.failNext.set('Email/query', 'forbidden');
|
||||
await flush(client);
|
||||
expect(client.sendEmail).not.toHaveBeenCalled();
|
||||
@@ -424,6 +426,7 @@ describe('wiping offline data', () => {
|
||||
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
|
||||
writeEnabled(['acc']);
|
||||
stop = startOfflineMode();
|
||||
usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: [] });
|
||||
usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, noOffline: true } } } });
|
||||
enforcePolicy();
|
||||
await vi.waitFor(async () => expect(useOfflineStore.getState().enabled).toEqual([]));
|
||||
|
||||
@@ -22,7 +22,7 @@ beforeEach(() => {
|
||||
setStoreBackend(store);
|
||||
seen.calls = 0;
|
||||
seen.onCall = () => {};
|
||||
usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
|
||||
usePolicyStore.setState({ loaded: true, subject: { email: 'alice@example.org', groups: [] }, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
|
||||
useAccountStore.setState({ accounts: [{ id: 'acc', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
|
||||
});
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ function fakeClient(server = 'https://mail.example.org', overrides: Record<strin
|
||||
}
|
||||
|
||||
function setPolicyOrg(organisation: Record<string, unknown>) {
|
||||
usePolicyStore.setState({ loaded: true, source: 'server', subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, ...organisation } as never } });
|
||||
usePolicyStore.setState({ loaded: true, source: 'server', subject: { email: 'alice@example.org', groups: [] }, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, ...organisation } as never } });
|
||||
}
|
||||
|
||||
function setOnline(online: boolean) {
|
||||
@@ -368,7 +368,7 @@ describe('what a sign-out would delete', () => {
|
||||
});
|
||||
|
||||
it('answers with the last known count when the store hangs', async () => {
|
||||
useOfflineStore.setState({ outbox: [{ id: 1, account: 'acc', createdAt: 1, subject: 'A', to: [], status: 'waiting' }] as never, pendingChanges: 2 });
|
||||
useOfflineStore.setState({ outbox: [{ id: 1, account: 'acc', createdAt: 1, subject: 'A', to: [], status: 'waiting' }] as never, changesBy: { acc: 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 });
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { DEFAULT_SCOPE } from '@/lib/store/area';
|
||||
import {
|
||||
attachmentCapOptions,
|
||||
effectiveScope,
|
||||
LEGACY_CHOICE,
|
||||
offlineDefaultOn,
|
||||
olderThanCap,
|
||||
platformDefault,
|
||||
userChoiceFor,
|
||||
windowOptions,
|
||||
} from '../scope';
|
||||
import { offlineScopeFor, useOfflineStore } from '../state';
|
||||
|
||||
const NO_LIMITS = DEFAULT_ORGANISATION_POLICY.devices;
|
||||
const MB = 1024 * 1024;
|
||||
const BROWSER = { on: false, scope: DEFAULT_SCOPE };
|
||||
const DESKTOP = { on: true, scope: DEFAULT_SCOPE };
|
||||
|
||||
describe('effectiveScope', () => {
|
||||
it('the default when nothing else says', () => {
|
||||
expect(effectiveScope('a', null, NO_LIMITS, BROWSER)).toMatchObject({ account: 'a', enabled: false, scope: DEFAULT_SCOPE });
|
||||
expect(effectiveScope('a', null, NO_LIMITS, DESKTOP).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('the user over the default', () => {
|
||||
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, orgMaxFileBytes: null });
|
||||
expect(effectiveScope('a', { on: false }, NO_LIMITS, DESKTOP).enabled).toBe(false);
|
||||
expect(effectiveScope('a', { days: null }, NO_LIMITS, BROWSER).scope.days).toBeNull();
|
||||
});
|
||||
|
||||
it('the organisation over the user and over the desktop default', () => {
|
||||
const policy = { ...NO_LIMITS, noOffline: true };
|
||||
expect(effectiveScope('a', { on: true }, policy, BROWSER).enabled).toBe(false);
|
||||
expect(effectiveScope('a', null, policy, DESKTOP).enabled).toBe(false);
|
||||
expect(effectiveScope('a', { on: true }, policy, DESKTOP).locked.offline).toBe(true);
|
||||
});
|
||||
|
||||
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, maxAttachmentBytes: 500 * MB }, policy, DESKTOP);
|
||||
expect(capped.scope).toMatchObject({ days: 60, attachments: true, maxAttachmentBytes: 10 * MB, maxTotalBytes: 100 * MB });
|
||||
expect(capped.locked).toMatchObject({ days: true, maxAttachment: true, maxTotal: true, attachments: false });
|
||||
// Sent on its own: a pin obeys this one, not the user's size.
|
||||
expect(capped.scope.orgMaxFileBytes).toBe(10 * MB);
|
||||
expect(effectiveScope('a', { maxAttachmentBytes: 5 * MB }, NO_LIMITS, BROWSER).scope.orgMaxFileBytes).toBeNull();
|
||||
expect(effectiveScope('a', null, { ...policy, offlineMaxDays: 30 }, DESKTOP).scope.days).toBe(30);
|
||||
const under = effectiveScope('a', { days: 30, maxAttachmentBytes: 5 * MB }, policy, BROWSER);
|
||||
expect(under.scope.days).toBe(30);
|
||||
expect(under.scope.maxAttachmentBytes).toBe(5 * MB);
|
||||
});
|
||||
|
||||
it('forbidden attachments stay off whatever the user chose', () => {
|
||||
const effective = effectiveScope('a', { attachments: true }, { ...NO_LIMITS, noOfflineAttachments: true }, DESKTOP);
|
||||
expect(effective.scope.attachments).toBe(false);
|
||||
expect(effective.locked.attachments).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the platform default', () => {
|
||||
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
|
||||
|
||||
it('is off in a browser and on with the desktop bridge', () => {
|
||||
expect(offlineDefaultOn()).toBe(false);
|
||||
expect(platformDefault().on).toBe(false);
|
||||
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 };
|
||||
expect(offlineDefaultOn()).toBe(true);
|
||||
expect(platformDefault().on).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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: {} }));
|
||||
|
||||
it('nothing for an account that never had it on', () => {
|
||||
expect(userChoiceFor('a')).toBeNull();
|
||||
});
|
||||
|
||||
it('an account on from before the local store keeps the 30 days it had', () => {
|
||||
useOfflineStore.setState({ enabled: ['a'] });
|
||||
expect(userChoiceFor('a')).toEqual(LEGACY_CHOICE);
|
||||
expect(LEGACY_CHOICE.days).toBe(30);
|
||||
expect(effectiveScope('a', userChoiceFor('a'), NO_LIMITS, BROWSER).scope.days).toBe(30);
|
||||
});
|
||||
|
||||
it('a stored choice wins', () => {
|
||||
useOfflineStore.setState({ enabled: ['a'], choices: { a: { on: true, days: 365 } } });
|
||||
expect(userChoiceFor('a')?.days).toBe(365);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what the page offers', () => {
|
||||
it('every window without a cap', () => {
|
||||
expect(windowOptions(0)).toEqual([30, 90, 365, null]);
|
||||
});
|
||||
|
||||
it('what fits under a cap, and the cap itself', () => {
|
||||
expect(windowOptions(90)).toEqual([30, 90]);
|
||||
expect(windowOptions(60)).toEqual([30, 60]);
|
||||
expect(windowOptions(7)).toEqual([7]);
|
||||
expect(attachmentCapOptions(0)).toEqual([5, 25, 100, 500]);
|
||||
expect(attachmentCapOptions(10)).toEqual([5, 10]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lite', () => {
|
||||
it('reads the caps from the same policy.json', async () => {
|
||||
const { applyLitePolicy } = await import('@/lib/lite-config');
|
||||
const policy = applyLitePolicy({ organisation: { devices: { offlineMaxDays: 30, noOfflineAttachments: true, offlineMaxTotalMb: 'lots' } } } as never);
|
||||
expect(policy.organisation?.devices).toMatchObject({ offlineMaxDays: 30, noOfflineAttachments: true, offlineMaxTotalMb: 0 });
|
||||
// A cap is lower than anything, also where nothing else is known.
|
||||
expect(effectiveScope('a', { days: null }, policy.organisation!.devices, BROWSER).scope).toMatchObject({ days: 30, attachments: false });
|
||||
});
|
||||
|
||||
it('has no store to apply them to, whatever a caller asks', async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock('@/lib/lite', async (original) => ({ ...(await original<typeof import('@/lib/lite')>()), IS_LITE: true }));
|
||||
try {
|
||||
const { enableOffline, offlineSupported } = await import('../offline-mode');
|
||||
const { useOfflineStore: lite } = await import('../state');
|
||||
expect(offlineSupported()).toBe(false);
|
||||
await enableOffline('a');
|
||||
expect(lite.getState().enabled).toEqual([]);
|
||||
} finally {
|
||||
vi.doUnmock('@/lib/lite');
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('olderThanCap', () => {
|
||||
const now = Date.UTC(2026, 10, 3);
|
||||
it('only with a cap, and only past it', () => {
|
||||
expect(olderThanCap('2026-01-01T00:00:00Z', 0, now)).toBe(false);
|
||||
expect(olderThanCap('2026-01-01T00:00:00Z', 30, now)).toBe(true);
|
||||
expect(olderThanCap('2026-10-30T00:00:00Z', 30, now)).toBe(false);
|
||||
expect(olderThanCap(undefined, 30, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { purgeSignedOutData } from '@/lib/sign-out-cleanup';
|
||||
import { configureStore, getStore, setStoreBackend } from '@/lib/store/client/api';
|
||||
import { deleteKeyDatabase, getAccountRecord, openAccountKey } from '@/lib/store/client/keys';
|
||||
import { FAKE_SESSION } from '@/lib/store/__tests__/helpers/fake-jmap';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import {
|
||||
enableOffline,
|
||||
registerOfflineHooks,
|
||||
setOfflineNotifier,
|
||||
startOfflineMode,
|
||||
wipeAccount,
|
||||
wipeEverything,
|
||||
type OfflineNotice,
|
||||
} from '../offline-mode';
|
||||
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 {
|
||||
static open = new Set<FakeChannel>();
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
constructor(readonly name: string) {
|
||||
FakeChannel.open.add(this);
|
||||
}
|
||||
postMessage(data: unknown): void {
|
||||
for (const channel of FakeChannel.open) {
|
||||
if (channel !== this && channel.name === this.name) channel.onmessage?.({ data } as MessageEvent);
|
||||
}
|
||||
}
|
||||
close(): void {
|
||||
FakeChannel.open.delete(this);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeWorker {
|
||||
static all: FakeWorker[] = [];
|
||||
/** 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() {
|
||||
FakeWorker.all.push(this);
|
||||
}
|
||||
postMessage(message: Record<string, unknown>): void {
|
||||
this.posted.push(message);
|
||||
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 {}
|
||||
}
|
||||
|
||||
const rpcs: Array<{ method: string; params: { name?: string; account?: string } }> = [];
|
||||
|
||||
/** The worker's side of the channel: an empty store. */
|
||||
function ownerOnChannel(): void {
|
||||
const owner = new FakeChannel('bulwark-store-rpc');
|
||||
owner.onmessage = (event) => {
|
||||
const message = event.data as { kind: string; id: string; from: string; method: string; params: { name?: string } };
|
||||
if (message.kind === 'who-owns') owner.postMessage({ kind: 'owner-changed', owner: 'worker' });
|
||||
if (message.kind !== 'rpc') return;
|
||||
rpcs.push({ method: message.method, params: message.params });
|
||||
const name = message.params.name ?? '';
|
||||
const result = name === 'outbox.read' ? { patches: 0, entries: [], conflicts: [] }
|
||||
: name.endsWith('.conflicts') ? []
|
||||
: name.endsWith('.pending') ? 0
|
||||
: name === 'store.usage' ? { bytes: 0, parts: [] }
|
||||
: null;
|
||||
owner.postMessage({ kind: 'reply', id: message.id, to: message.from, result });
|
||||
};
|
||||
owner.postMessage({ kind: 'owner-changed', owner: 'worker' });
|
||||
}
|
||||
|
||||
const notices: OfflineNotice[] = [];
|
||||
|
||||
function setStorage(storage: Record<string, unknown>): void {
|
||||
Object.defineProperty(window.navigator, 'storage', { configurable: true, value: storage });
|
||||
}
|
||||
|
||||
function setUserAgent(value: string): void {
|
||||
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value });
|
||||
}
|
||||
|
||||
const realUserAgent = window.navigator.userAgent;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
deleteKeyDatabase();
|
||||
FakeChannel.open.clear();
|
||||
FakeWorker.all = [];
|
||||
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, ...rest: unknown[]) => (rest.at(-1) as () => unknown)() } });
|
||||
useOfflineStore.setState({
|
||||
enabled: [], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usage: {}, usageBytes: 0, choices: {}, health: {},
|
||||
storeLost: [], storageFull: {}, progress: {}, persist: 'unknown', syncing: [], lastSyncedAt: {}, browserOffline: false, downServers: [], startedOffline: false,
|
||||
});
|
||||
usePolicyStore.setState({ loaded: true, source: 'server', subject: { email: 'alice@example.org', groups: [] }, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never);
|
||||
setStoreBackend(null);
|
||||
setOfflineNotifier((notice) => notices.push(notice));
|
||||
registerOfflineHooks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getStore().stop();
|
||||
configureStore(null);
|
||||
setStoreBackend(null);
|
||||
setOfflineNotifier(() => {});
|
||||
vi.unstubAllGlobals();
|
||||
delete (window.navigator as { storage?: unknown }).storage;
|
||||
delete (window.navigator as { locks?: unknown }).locks;
|
||||
setUserAgent(realUserAgent);
|
||||
});
|
||||
|
||||
/** Opens an account in this tab, as the owner, the way the page does. */
|
||||
async function openAsOwner(account: string): Promise<FakeWorker> {
|
||||
useOfflineStore.setState((s) => ({ enabled: [...s.enabled, account] }));
|
||||
await getStore().ensureOpen(account, FAKE_SESSION);
|
||||
const worker = FakeWorker.all.at(-1)!;
|
||||
worker.onmessage?.({ data: { kind: 'owner' } } as MessageEvent);
|
||||
ownerOnChannel();
|
||||
await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'open' && (m.params as { account: string }).account === account)).toBe(true));
|
||||
return worker;
|
||||
}
|
||||
|
||||
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');
|
||||
hadStore('acc');
|
||||
writeHealth('acc', { pending: 3 });
|
||||
FakeWorker.created = true;
|
||||
await openAsOwner('acc');
|
||||
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 () => {
|
||||
hadStore('acc');
|
||||
await openAsOwner('acc');
|
||||
await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'store-reset', lost: 0 }));
|
||||
});
|
||||
|
||||
it('a first open is not an eviction', async () => {
|
||||
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');
|
||||
hadStore('acc');
|
||||
writeHealth('acc', { pending: 3 });
|
||||
await openAsOwner('acc');
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(notices).toEqual([]);
|
||||
expect(useOfflineStore.getState().health.acc.pending).toBe(3);
|
||||
});
|
||||
|
||||
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('says so also when the store opened before the host component was there', async () => {
|
||||
setOfflineNotifier(null);
|
||||
await openAccountKey('acc');
|
||||
hadStore('acc');
|
||||
FakeWorker.created = true;
|
||||
const worker = await openAsOwner('acc');
|
||||
await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'open')).toBe(true));
|
||||
await vi.waitFor(() => expect(useOfflineStore.getState().storeLost).toEqual(['acc']));
|
||||
expect(notices).toEqual([]);
|
||||
setOfflineNotifier((notice) => notices.push(notice));
|
||||
expect(notices).toContainEqual({ kind: 'store-reset', lost: 0 });
|
||||
});
|
||||
|
||||
it('off and on again is no eviction', async () => {
|
||||
await openAccountKey('acc');
|
||||
hadStore('acc');
|
||||
writeEnabled(['acc']);
|
||||
await wipeAccount('acc');
|
||||
expect(localStorage.getItem('bulwark-store-opened')).toBeNull();
|
||||
FakeWorker.created = true;
|
||||
await openAsOwner('acc');
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(notices.filter((n) => n.kind === 'store-reset')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps only numbers about it outside the store', () => {
|
||||
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 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the scope an account opens with', () => {
|
||||
const setDays = (offlineMaxDays: number) => usePolicyStore.setState({
|
||||
policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, offlineMaxDays } } },
|
||||
} as never);
|
||||
const openParams = (worker: FakeWorker) => worker.posted.find((m) => m.kind === 'open')?.params as { scope?: { days: number | null } } | undefined;
|
||||
|
||||
function holdPolicy(): () => void {
|
||||
let release = () => {};
|
||||
const fetchPolicy = vi.fn(() => new Promise<void>((resolve) => { release = resolve; }));
|
||||
usePolicyStore.setState({ fetchPolicy } as never);
|
||||
return () => release();
|
||||
}
|
||||
|
||||
afterEach(() => { usePolicyStore.setState({ fetchPolicy: realFetchPolicy } as never); });
|
||||
const realFetchPolicy = usePolicyStore.getState().fetchPolicy;
|
||||
|
||||
it('none from a tab that just took over, and the cap once it has heard the policy', async () => {
|
||||
const heard = holdPolicy();
|
||||
setDays(14);
|
||||
const worker = await openAsOwner('acc');
|
||||
await vi.waitFor(() => expect(openParams(worker)).toBeTruthy());
|
||||
// This tab's policy may be old: the store keeps what it has.
|
||||
expect(openParams(worker)?.scope).toBeUndefined();
|
||||
expect(rpcs.filter((rpc) => rpc.method === 'setScope')).toEqual([]);
|
||||
|
||||
heard();
|
||||
await vi.waitFor(() => expect(rpcs.filter((rpc) => rpc.method === 'setScope')).toHaveLength(1));
|
||||
expect((rpcs.find((rpc) => rpc.method === 'setScope')!.params as unknown as { scope: { days: number } }).scope.days).toBe(14);
|
||||
});
|
||||
|
||||
it('a stale cap the fetch corrects isn\'t what goes out', async () => {
|
||||
const heard = holdPolicy();
|
||||
setDays(365);
|
||||
await openAsOwner('acc');
|
||||
setDays(14);
|
||||
heard();
|
||||
await vi.waitFor(() => expect(rpcs.filter((rpc) => rpc.method === 'setScope')).toHaveLength(1));
|
||||
expect((rpcs.find((rpc) => rpc.method === 'setScope')!.params as unknown as { scope: { days: number } }).scope.days).toBe(14);
|
||||
});
|
||||
|
||||
it('none before the policy is known, at the real inputs', async () => {
|
||||
usePolicyStore.setState({ source: 'none' } as never);
|
||||
const worker = await openAsOwner('acc');
|
||||
await vi.waitFor(() => expect(openParams(worker)).toBeTruthy());
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(rpcs.filter((rpc) => rpc.method === 'setScope')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a failed write', () => {
|
||||
it('says the device is full, at most once in a while, until a sync goes through', async () => {
|
||||
const worker = await openAsOwner('acc');
|
||||
const event = (e: unknown) => worker.onmessage?.({ data: { kind: 'event', event: e } } as MessageEvent);
|
||||
event({ type: 'error', account: 'acc', area: 'storage', message: 'SQLITE_FULL' });
|
||||
event({ type: 'error', account: 'acc', area: 'storage', message: 'SQLITE_FULL' });
|
||||
expect(notices.filter((n) => n.kind === 'storage-failed').length).toBeLessThanOrEqual(1);
|
||||
expect(useOfflineStore.getState().storageFull.acc).toBeGreaterThan(0);
|
||||
event({ type: 'synced', account: 'acc', at: 123 });
|
||||
expect(useOfflineStore.getState().storageFull.acc).toBeUndefined();
|
||||
expect(useOfflineStore.getState().health.acc?.lastSync).toBe(123);
|
||||
});
|
||||
});
|
||||
|
||||
describe('asking the browser to keep it', () => {
|
||||
it('asks when offline is turned on, and remembers a refusal', async () => {
|
||||
const persist = vi.fn(async () => false);
|
||||
setStorage({ persist, persisted: async () => false, getDirectory: async () => ({}) });
|
||||
await enableOffline('acc').catch(() => {});
|
||||
await vi.waitFor(() => expect(useOfflineStore.getState().persist).toBe('refused'));
|
||||
expect(persist).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('asks again after an install', async () => {
|
||||
const persist = vi.fn(async () => true);
|
||||
setStorage({ persist, persisted: async () => false, getDirectory: async () => ({}) });
|
||||
writeEnabled(['acc']);
|
||||
const stop = startOfflineMode();
|
||||
window.dispatchEvent(new Event('appinstalled'));
|
||||
await vi.waitFor(() => expect(persist).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(useOfflineStore.getState().persist).toBe('granted'));
|
||||
stop();
|
||||
registerOfflineHooks();
|
||||
});
|
||||
|
||||
it('tells a browser without the API from a refusal', async () => {
|
||||
setStorage({});
|
||||
expect(await requestPersistence()).toBe('unsupported');
|
||||
expect(await readPersistence()).toBe('unsupported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Safari hint', () => {
|
||||
const IPHONE = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
|
||||
const IPAD_CHROME = 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0 Mobile/15E148 Safari/604.1';
|
||||
const MAC_SAFARI = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15';
|
||||
const MAC_CHROME = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
|
||||
const WIN_EDGE = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 Edg/126.0';
|
||||
const ANDROID_WEBVIEW = 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/126.0 Mobile Safari/537.36';
|
||||
const FIREFOX = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.5; rv:128.0) Gecko/20100101 Firefox/128.0';
|
||||
const ELECTRON = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Bulwark/1.0 Chrome/126.0 Electron/31.0 Safari/537.36';
|
||||
|
||||
it('shows in Safari and on iOS browsers that are not installed', () => {
|
||||
for (const ua of [IPHONE, IPAD_CHROME, MAC_SAFARI]) expect(safariMayDelete(ua, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('not once installed', () => {
|
||||
for (const ua of [IPHONE, MAC_SAFARI]) expect(safariMayDelete(ua, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('nowhere else', () => {
|
||||
for (const ua of [MAC_CHROME, WIN_EDGE, ANDROID_WEBVIEW, FIREFOX, ELECTRON]) expect(safariMayDelete(ua, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the real browser by default', () => {
|
||||
setUserAgent(MAC_SAFARI);
|
||||
expect(safariMayDelete()).toBe(true);
|
||||
setUserAgent(MAC_CHROME);
|
||||
expect(safariMayDelete()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removing an account', () => {
|
||||
it('takes its data, key, choice and counts, and leaves the other account alone', async () => {
|
||||
const { record } = await openAccountKey('gone');
|
||||
await openAccountKey('stays');
|
||||
writeEnabled(['gone', 'stays']);
|
||||
writeChoice('gone', { days: 30 });
|
||||
writeChoice('stays', { days: 90 });
|
||||
writeHealth('gone', { pending: 1 });
|
||||
await openAsOwner('stays');
|
||||
|
||||
await wipeAccount('gone');
|
||||
|
||||
expect(await getAccountRecord('gone')).toBeNull();
|
||||
expect(await getAccountRecord('stays')).not.toBeNull();
|
||||
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_SCOPE_KEY)!)).toEqual({ stays: { days: 90 } });
|
||||
});
|
||||
|
||||
it('with the last account, deletes the pool and every key', async () => {
|
||||
const removeEntry = vi.fn(async () => {});
|
||||
setStorage({ getDirectory: async () => ({ removeEntry }), persist: async () => true });
|
||||
await openAccountKey('acc');
|
||||
writeEnabled(['acc']);
|
||||
writeChoice('acc', { days: 30 });
|
||||
|
||||
await wipeAccount('acc');
|
||||
|
||||
expect(removeEntry).toHaveBeenCalledWith('.bulwark-store', { recursive: true });
|
||||
await vi.waitFor(async () => expect(await getAccountRecord('acc')).toBeNull());
|
||||
expect(localStorage.getItem(OFFLINE_SCOPE_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(OFFLINE_HEALTH_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('wipeEverything leaves nothing either', async () => {
|
||||
const removeEntry = vi.fn(async () => {});
|
||||
setStorage({ getDirectory: async () => ({ removeEntry }) });
|
||||
await openAccountKey('a');
|
||||
await openAccountKey('b');
|
||||
writeEnabled(['a', 'b']);
|
||||
await wipeEverything();
|
||||
expect(removeEntry).toHaveBeenCalled();
|
||||
await vi.waitFor(async () => expect(await getAccountRecord('a')).toBeNull());
|
||||
expect(await getAccountRecord('b')).toBeNull();
|
||||
expect(useOfflineStore.getState().enabled).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a full sign-out', () => {
|
||||
it('that keeps the local store for a new sign-in keeps what was chosen for it too', () => {
|
||||
writeEnabled(['acc']);
|
||||
writeChoice('acc', { days: 30 });
|
||||
writeHealth('acc', { pending: 2 });
|
||||
purgeSignedOutData({ keepLocalStore: true });
|
||||
expect(JSON.parse(localStorage.getItem(OFFLINE_SCOPE_KEY)!).acc.days).toBe(30);
|
||||
expect(JSON.parse(localStorage.getItem(OFFLINE_HEALTH_KEY)!).acc.pending).toBe(2);
|
||||
expect(useOfflineStore.getState().choices.acc.days).toBe(30);
|
||||
});
|
||||
|
||||
it('deletes the files, every key and what was stored about them', async () => {
|
||||
const removeEntry = vi.fn(async () => {});
|
||||
setStorage({ getDirectory: async () => ({ removeEntry }) });
|
||||
await openAccountKey('acc');
|
||||
writeEnabled(['acc']);
|
||||
writeChoice('acc', { days: 30 });
|
||||
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_SCOPE_KEY, OFFLINE_HEALTH_KEY]) expect(localStorage.getItem(key)).toBeNull();
|
||||
expect(useOfflineStore.getState().choices).toEqual({});
|
||||
expect(useOfflineStore.getState().health).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { minutesLeft, nextProgress, progressWorthShowing } from '../sync-progress';
|
||||
|
||||
const event = (phase: string, done: number, total: number | null) => ({ area: 'mail', phase, done, total });
|
||||
|
||||
describe('the time left', () => {
|
||||
it('says nothing until it has seen enough', () => {
|
||||
const start = nextProgress(undefined, event('bodies', 0, 1000), 0);
|
||||
expect(minutesLeft(start)).toBeNull();
|
||||
expect(minutesLeft(nextProgress(start, event('bodies', 100, 1000), 1_000))).toBeNull();
|
||||
});
|
||||
|
||||
it('works from the rate of this phase', () => {
|
||||
const start = nextProgress(undefined, event('bodies', 0, 6000), 0);
|
||||
// 100 a second: 59 s left rounds up.
|
||||
expect(minutesLeft(nextProgress(start, event('bodies', 100, 6000), 1_000))).toBeNull();
|
||||
expect(minutesLeft(nextProgress(start, event('bodies', 600, 6000), 6_000))).toBe(1);
|
||||
});
|
||||
|
||||
it('while listing, counts the bodies that come after', () => {
|
||||
const start = nextProgress(undefined, event('list', 0, 50_000), 0);
|
||||
const left = minutesLeft(nextProgress(start, event('list', 7_000, 50_000), 5_000));
|
||||
// Bench rig: 35 s to list, about 7 minutes in all.
|
||||
expect(left).toBeGreaterThanOrEqual(5);
|
||||
expect(left).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('starts over with each phase', () => {
|
||||
const list = nextProgress(undefined, event('list', 1000, 1000), 0);
|
||||
const bodies = nextProgress(list, event('bodies', 10, 1000), 60_000);
|
||||
expect(bodies.startedAt).toBe(60_000);
|
||||
expect(bodies.startedDone).toBe(10);
|
||||
});
|
||||
|
||||
it('starts the sample over after a gap: time asleep isn\'t the rate', () => {
|
||||
const start = nextProgress(undefined, event('bodies', 0, 10_000), 0);
|
||||
const before = nextProgress(start, event('bodies', 100, 10_000), 10_000);
|
||||
const after = nextProgress(before, event('bodies', 200, 10_000), 10_000 + 3_600_000);
|
||||
expect(after.startedAt).toBe(10_000 + 3_600_000);
|
||||
expect(after.startedDone).toBe(200);
|
||||
expect(minutesLeft(after)).toBeNull();
|
||||
});
|
||||
|
||||
it('has no time without a total, or when done', () => {
|
||||
const open = nextProgress(undefined, event('list', 0, null), 0);
|
||||
expect(minutesLeft(nextProgress(open, event('list', 500, null), 10_000))).toBeNull();
|
||||
const done = nextProgress(undefined, event('bodies', 0, 100), 0);
|
||||
expect(minutesLeft(nextProgress(done, event('bodies', 100, 100), 10_000))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what is worth a line', () => {
|
||||
it('a big job or an unknown one, not a few new messages', () => {
|
||||
expect(progressWorthShowing(undefined)).toBe(false);
|
||||
expect(progressWorthShowing(nextProgress(undefined, event('bodies', 1, 3), 0))).toBe(false);
|
||||
expect(progressWorthShowing(nextProgress(undefined, event('bodies', 1, 500), 0))).toBe(true);
|
||||
expect(progressWorthShowing(nextProgress(undefined, event('list', 500, null), 0))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,8 @@ import { getStore } from '@/lib/store/client/api';
|
||||
import type { StoreSession } from '@/lib/store/engine/transport';
|
||||
import type { LegacyImport } from '@/lib/store/outbox/calls';
|
||||
import type { OutboxError, OutboxStatus, OutboxSummary, PolicyFingerprint, SendItem } from '@/lib/store/outbox/items';
|
||||
import { useOfflineStore } from './state';
|
||||
import { LEGACY_CHOICE } from './scope';
|
||||
import { useOfflineStore, writeChoice } from './state';
|
||||
|
||||
export const LEGACY_DB_NAME = 'bulwark-offline';
|
||||
|
||||
@@ -173,6 +174,8 @@ async function moveNow(): Promise<void> {
|
||||
const keyRecord = await result(db.transaction('keys', 'readonly').objectStore('keys').get('aes')) as { key: CryptoKey } | undefined;
|
||||
const key = keyRecord?.key ?? null;
|
||||
for (const account of useOfflineStore.getState().enabled) {
|
||||
// Keeps the window it had: a choice now, so a later default can't change it.
|
||||
if (!useOfflineStore.getState().choices[account]) writeChoice(account, LEGACY_CHOICE);
|
||||
const part = await readAccount(db, key, account);
|
||||
const { session, ...content } = part;
|
||||
if (session) await getStore().ensureOpen(account, session);
|
||||
|
||||
+323
-32
@@ -24,13 +24,13 @@
|
||||
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { desktopJmapBase } from '@/lib/desktop/jmap-route';
|
||||
import { devicesForAccount } from '@/lib/device-policy';
|
||||
import { devicesForAccount, devicesForNoOne, signedInAccountIds } from '@/lib/device-policy';
|
||||
import { accountSubjectPending, useAccountSubjects } from '@/lib/org/account-subjects';
|
||||
import { labelsPolicyFor } from '@/lib/outgoing/classification';
|
||||
import { getSendGuardSource, guardState, guardsSending } from '@/lib/plugin-send-guards';
|
||||
import { emitPushStateChange } from '@/lib/push-events';
|
||||
import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync';
|
||||
import { STORAGE_ERROR_AREA } from '@/lib/store/area';
|
||||
import { STORAGE_ERROR_AREA, type StoreScope, type StoreUsage } from '@/lib/store/area';
|
||||
import type { DraftError } from '@/lib/store/drafts/pending';
|
||||
import { configureStore, getStore, type StorePageHooks } from '@/lib/store/client/api';
|
||||
import { purgeStoreFiles } from '@/lib/store/client/purge';
|
||||
@@ -57,7 +57,36 @@ import {
|
||||
type PolicyFingerprint,
|
||||
} from './queue';
|
||||
import { clearHandoffs } from '@/lib/launch-handoff';
|
||||
import { clearEvicted, EVICTED_KEEP_MS, forgetMailSnapshot, isEvicted, offlineScopeFor, isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, readEvicted, visibleAccounts, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state';
|
||||
import {
|
||||
clearEvicted,
|
||||
EVICTED_KEEP_MS,
|
||||
forgetAccountState,
|
||||
forgetAllAccountState,
|
||||
forgetMailSnapshot,
|
||||
isEvicted,
|
||||
isOfflineNow,
|
||||
OFFLINE_ACCOUNTS_KEY,
|
||||
OFFLINE_HEALTH_KEY,
|
||||
OFFLINE_SCOPE_KEY,
|
||||
readEnabledFrom,
|
||||
readEvicted,
|
||||
readStoredChoices,
|
||||
readStoredHealth,
|
||||
serverKey,
|
||||
setServerDown,
|
||||
useOfflineStore,
|
||||
visibleAccounts,
|
||||
writeChoice,
|
||||
writeEnabled,
|
||||
writeHealth,
|
||||
type AreaConflict,
|
||||
type OutboxRow,
|
||||
type UserChoice,
|
||||
} from './state';
|
||||
import { effectiveScope, offlineDefaultOn, olderThanCap, platformDefault, userChoiceFor, type EffectiveScope } from './scope';
|
||||
import { isStandaloneDisplayMode } from '@/lib/standalone-display';
|
||||
import { readPersistence, requestPersistence, safariMayDelete } from './storage-health';
|
||||
import { nextProgress } from './sync-progress';
|
||||
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';
|
||||
@@ -74,8 +103,9 @@ export type OfflineNotice =
|
||||
| { kind: 'draft-conflict'; subject: string }
|
||||
| { kind: 'storage-failed' }
|
||||
| { kind: 'store-unavailable' }
|
||||
| { kind: 'store-reset' }
|
||||
| { kind: 'store-reset'; lost?: number }
|
||||
| { kind: 'legacy-stranded' }
|
||||
| { kind: 'default-on' }
|
||||
| { kind: 'refused-delete' }
|
||||
| { kind: 'refused-other' }
|
||||
| { kind: 'forbidden' }
|
||||
@@ -94,10 +124,10 @@ function notify(notice: OfflineNotice): void {
|
||||
else if (earlyNotices.length < MOST_EARLY_NOTICES) earlyNotices.push(notice);
|
||||
}
|
||||
|
||||
/** Where the notices go (the host component turns them into toasts). */
|
||||
export function setOfflineNotifier(fn: (notice: OfflineNotice) => void): void {
|
||||
/** Where the notices go (the host component turns them into toasts). Null: nothing is there at the moment. */
|
||||
export function setOfflineNotifier(fn: ((notice: OfflineNotice) => void) | null): void {
|
||||
notifier = fn;
|
||||
for (const notice of earlyNotices.splice(0)) fn(notice);
|
||||
if (fn) for (const notice of earlyNotices.splice(0)) fn(notice);
|
||||
}
|
||||
|
||||
export function offlineNotify(notice: OfflineNotice): void {
|
||||
@@ -130,6 +160,16 @@ export function offlineAllowed(account: string | null | undefined): boolean {
|
||||
return offlineSupported() && !devicesForAccount(account ?? null).noOffline;
|
||||
}
|
||||
|
||||
/** A dropped account's data isn't judged by the groups of whoever is signed in now. */
|
||||
function policyOfKept(account: string) {
|
||||
return isEvicted(account) ? devicesForNoOne() : devicesForAccount(account);
|
||||
}
|
||||
|
||||
/** What this account keeps here: the organisation's policy over the user's choice over the platform's default. */
|
||||
export function scopeForAccount(account: string): EffectiveScope {
|
||||
return effectiveScope(account, userChoiceFor(account), policyOfKept(account), platformDefault());
|
||||
}
|
||||
|
||||
/** "Keep mail on this device" is on for this account here, and allowed. */
|
||||
export function offlineActive(account: string | null | undefined): boolean {
|
||||
return !!account && useOfflineStore.getState().enabled.includes(account) && offlineAllowed(account);
|
||||
@@ -158,6 +198,9 @@ function onlineClientFor(account: string) {
|
||||
|
||||
// ---- The store's way to this tab ----
|
||||
|
||||
/** How long a tab that took over the store waits for the policy before going on as if it hadn't heard. */
|
||||
const OWNER_POLICY_WAIT_MS = 5_000;
|
||||
|
||||
/** A full disk stays full: once in a while is enough to say so. */
|
||||
const STORAGE_NOTICE_MS = 30 * 60_000;
|
||||
let lastStorageNotice = 0;
|
||||
@@ -178,8 +221,10 @@ const storeHooks: StorePageHooks = {
|
||||
return {
|
||||
session: (client?.getSessionSnapshot() as StoreSession | undefined) ?? null,
|
||||
auth: client?.getAuthHeader() ?? null,
|
||||
// Until the policy is known the store keeps its last scope, so a slow
|
||||
// start doesn't read as a wider window that has to be fetched.
|
||||
scope: scopeDecided(account) ? scopeForAccount(account).scope : undefined,
|
||||
invertedKeywordSort: client?.keywordSortInverted() ?? null,
|
||||
scope: offlineScopeFor(account),
|
||||
};
|
||||
},
|
||||
async refreshAuth(account) {
|
||||
@@ -189,19 +234,40 @@ const storeHooks: StorePageHooks = {
|
||||
await client.requestNetworkOnly([['Core/echo', {}, '0']]);
|
||||
return client.getAuthHeader();
|
||||
},
|
||||
async ownerTaken() {
|
||||
// This tab may have sat in the background: its policy can be old.
|
||||
await Promise.race([
|
||||
usePolicyStore.getState().fetchPolicy(),
|
||||
new Promise((resolve) => setTimeout(resolve, OWNER_POLICY_WAIT_MS)),
|
||||
]);
|
||||
},
|
||||
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])] }));
|
||||
notify({ kind: 'store-reset' });
|
||||
writeHealth(account, { pending: 0, reset: { at: Date.now(), lost } });
|
||||
notify({ kind: 'store-reset', lost });
|
||||
void refreshQueueState();
|
||||
},
|
||||
onEvent(event) {
|
||||
if (event.type === 'synced') {
|
||||
useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at } }));
|
||||
useOfflineStore.setState((s) => {
|
||||
const { [event.account]: _full, ...storageFull } = s.storageFull;
|
||||
const { [event.account]: _progress, ...progress } = s.progress;
|
||||
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();
|
||||
}
|
||||
if (event.type === 'progress') {
|
||||
useOfflineStore.setState((s) => ({ progress: { ...s.progress, [event.account]: nextProgress(s.progress[event.account], event, Date.now()) } }));
|
||||
}
|
||||
if (event.type === 'error' && event.area === STORAGE_ERROR_AREA) {
|
||||
useOfflineStore.setState((s) => ({ storageFull: { ...s.storageFull, [event.account]: s.storageFull[event.account] ?? Date.now() } }));
|
||||
const now = Date.now();
|
||||
if (now - lastStorageNotice < STORAGE_NOTICE_MS) return;
|
||||
lastStorageNotice = now;
|
||||
@@ -235,6 +301,9 @@ function noticeRefused(answered: OfflineResponse | null, methodCalls: OfflineMet
|
||||
notify({ kind: emptied ? 'refused-delete' : 'refused-other' });
|
||||
}
|
||||
|
||||
/** What the store may keep for later when it answers for the server. */
|
||||
const QUEUEING_METHOD = /^(Email|CalendarEvent|Calendar|ContactCard|AddressBook)\/set$/;
|
||||
|
||||
const probes = new Map<string, Promise<boolean>>();
|
||||
/** A black-holed connection never answers the probe either. */
|
||||
const PROBE_TIMEOUT_MS = 5_000;
|
||||
@@ -283,7 +352,7 @@ const hooks: OfflineHooks = {
|
||||
// The store couldn't start or lost its owner: as if nothing were kept.
|
||||
}
|
||||
noticeRefused(answered, methodCalls);
|
||||
if (methodCalls.some(([name]) => name === 'Email/set') && answered) {
|
||||
if (answered && methodCalls.some(([name]) => QUEUEING_METHOD.test(name))) {
|
||||
requestOutboxSync();
|
||||
void refreshQueueState();
|
||||
}
|
||||
@@ -320,7 +389,11 @@ const hooks: OfflineHooks = {
|
||||
if (!offlineActive(account) || !account) return;
|
||||
for (const [name, result, callId] of response.methodResponses) {
|
||||
if (name !== 'Email/get' || !Array.isArray(result?.list)) continue;
|
||||
const opened = (result.list as Array<{ bodyValues?: Record<string, { isTruncated?: boolean }> }>).filter(hasWholeText);
|
||||
// Past the organisation's cap a message isn't kept, however it got opened.
|
||||
const cap = devicesForAccount(account).offlineMaxDays;
|
||||
const now = Date.now();
|
||||
const opened = (result.list as Array<{ bodyValues?: Record<string, { isTruncated?: boolean }>; receivedAt?: unknown }>)
|
||||
.filter((email) => hasWholeText(email) && !olderThanCap(email.receivedAt, cap, now));
|
||||
if (opened.length === 0) continue;
|
||||
const args = methodCalls.find(([n, , id]) => id === callId && n === name)?.[1];
|
||||
const jmap = (result.accountId as string) || (args?.accountId as string) || client.getAccountId();
|
||||
@@ -536,13 +609,13 @@ export async function refreshQueueState(): Promise<void> {
|
||||
// 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 (visibleAccounts().length === 0) {
|
||||
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], localDrafts: [] });
|
||||
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], localDrafts: [] });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Sends of a tab closed on the way: uncertain, so they can be looked up.
|
||||
await settleStaleSends();
|
||||
const [{ failed, patches, outbox: entries, conflicts }, drafts] = await Promise.all([readQueue(), readLocalDrafts()]);
|
||||
const [{ failed, patches, patchesBy, outbox: entries, conflicts }, drafts] = await Promise.all([readQueue(), readLocalDrafts()]);
|
||||
if (failed.length > 0) noteStoreUnavailable();
|
||||
const rows: OutboxRow[] = entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
@@ -557,19 +630,90 @@ export async function refreshQueueState(): Promise<void> {
|
||||
const shown = useOfflineStore.getState();
|
||||
const unread = shown.outbox.filter((row) => failed.includes(row.account));
|
||||
const unreadDrafts = shown.localDrafts.filter((row) => failed.includes(row.account));
|
||||
useOfflineStore.setState({ outbox: [...unread, ...rows], pendingChanges: patches, conflicts, localDrafts: [...unreadDrafts, ...drafts] });
|
||||
const accounts = visibleAccounts();
|
||||
const areas = await Promise.all(accounts.map(readAreaState));
|
||||
const changesBy = Object.fromEntries(accounts.map((account, index) => [account, (patchesBy[account] ?? 0) + areas[index].pending]));
|
||||
useOfflineStore.setState((s) => ({
|
||||
outbox: [...unread, ...rows],
|
||||
pendingChanges: patches,
|
||||
conflicts,
|
||||
localDrafts: [...unreadDrafts, ...drafts],
|
||||
areaConflicts: areas.flatMap((area) => area.conflicts),
|
||||
// An account that couldn't answer counts with what it last said.
|
||||
changesBy: { ...changesBy, ...Object.fromEntries(failed.map((account) => [account, s.changesBy[account] ?? 0])) },
|
||||
}));
|
||||
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. 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 + drafts.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();
|
||||
}
|
||||
}
|
||||
|
||||
interface StoredCalendarConflict { id: number; createdAt: number; title: string | null; reason: 'refused' | 'changedOnServer' | 'destroyedOnServer'; kind: string; description: string | null; errorType: string | null }
|
||||
interface StoredContactConflict { id: number; createdAt: number; summary: string; kind: 'refused' | 'changed' | 'gone'; local: { op?: string }; error: string | null }
|
||||
|
||||
/** What the calendar and contacts have waiting or couldn't send. An area this build doesn't have counts as empty. */
|
||||
async function readAreaState(account: string): Promise<{ conflicts: AreaConflict[]; pending: number }> {
|
||||
const read = <T,>(name: string, none: T) => getStore().area<T | null | undefined>(account, name).then((value) => value ?? none, () => none);
|
||||
const [calendar, contacts, calendarPending, contactsPending] = await Promise.all([
|
||||
read<StoredCalendarConflict[]>('calendar.conflicts', []),
|
||||
read<StoredContactConflict[]>('contacts.conflicts', []),
|
||||
read<number>('calendar.pending', 0),
|
||||
read<number>('contacts.pending', 0),
|
||||
]);
|
||||
const conflicts: AreaConflict[] = [
|
||||
...calendar.map((row): AreaConflict => ({
|
||||
area: 'calendar',
|
||||
account,
|
||||
id: row.id,
|
||||
createdAt: row.createdAt,
|
||||
title: row.title ?? '',
|
||||
reason: row.reason === 'refused' ? 'refused' : row.reason === 'destroyedOnServer' ? 'gone' : 'changed',
|
||||
destroy: row.kind === 'destroy',
|
||||
detail: row.description ?? row.errorType,
|
||||
})),
|
||||
...contacts.map((row): AreaConflict => ({
|
||||
area: 'contacts',
|
||||
account,
|
||||
id: row.id,
|
||||
createdAt: row.createdAt,
|
||||
title: row.summary,
|
||||
reason: row.kind,
|
||||
destroy: row.local?.op === 'destroy',
|
||||
detail: row.error,
|
||||
})),
|
||||
];
|
||||
return { conflicts, pending: calendarPending + contactsPending };
|
||||
}
|
||||
|
||||
/** The user has seen it: the calendar's or the contacts' copy stays as the server has it. */
|
||||
export async function dismissAreaConflict(conflict: AreaConflict): Promise<void> {
|
||||
await getStore().area(conflict.account, `${conflict.area}.dismissConflict`, { id: conflict.id });
|
||||
await refreshQueueState();
|
||||
}
|
||||
|
||||
export async function refreshUsage(): Promise<void> {
|
||||
if (!offlineSupported() || visibleAccounts().length === 0) return;
|
||||
const accounts = visibleAccounts();
|
||||
if (!offlineSupported() || accounts.length === 0) return;
|
||||
try {
|
||||
const usage = await Promise.all(visibleAccounts().map((account) => getStore().area<{ bytes: number }>(account, 'mail.usage')));
|
||||
useOfflineStore.setState({ usageBytes: usage.reduce((sum, { bytes }) => sum + bytes, 0) });
|
||||
const settled = await Promise.allSettled(accounts.map((account) => getStore().area<StoreUsage>(account, 'store.usage')));
|
||||
// One account's store being in trouble says nothing about the others'.
|
||||
useOfflineStore.setState((s) => {
|
||||
const usage = { ...s.usage };
|
||||
accounts.forEach((account, index) => {
|
||||
const result = settled[index];
|
||||
if (result.status === 'fulfilled' && result.value) usage[account] = result.value;
|
||||
});
|
||||
return { usage, usageBytes: accounts.reduce((sum, account) => sum + (usage[account]?.bytes ?? 0), 0) };
|
||||
});
|
||||
} catch {
|
||||
/* deleted meanwhile */
|
||||
}
|
||||
@@ -665,6 +809,7 @@ async function flushAreas(account: string, client: NonNullable<ReturnType<typeof
|
||||
const tally = await getStore().area<{ sent?: number; conflicts?: number | unknown[] } | null>(account, call).catch(() => null);
|
||||
if (!tally) continue;
|
||||
const conflicts = Array.isArray(tally.conflicts) ? tally.conflicts.length : tally.conflicts ?? 0;
|
||||
if (conflicts > 0) notify({ kind: 'conflict', count: conflicts });
|
||||
if (!tally.sent && !conflicts) continue;
|
||||
emitTypesChanged(client, types);
|
||||
}
|
||||
@@ -710,13 +855,12 @@ export async function flushAll(): Promise<void> {
|
||||
export async function enableOffline(account: string): Promise<void> {
|
||||
if (!offlineAllowed(account)) return;
|
||||
const enabled = useOfflineStore.getState().enabled;
|
||||
// Before the list: an account on the list without a choice is one from before the local store.
|
||||
writeChoice(account, { on: true });
|
||||
if (!enabled.includes(account)) writeEnabled([...enabled, account]);
|
||||
// Ask the browser not to clear it when space runs low (it may say no).
|
||||
try {
|
||||
void navigator.storage?.persist?.().catch(() => false);
|
||||
} catch {
|
||||
/* no Storage API */
|
||||
}
|
||||
// Ask the browser not to clear it when space runs low. It may say no, which
|
||||
// the settings page shows, with a way to ask again.
|
||||
void requestPersistence();
|
||||
await getStore().ensureOpen(account).catch(() => {});
|
||||
await tellServiceWorker(true);
|
||||
scheduleSync(account, 0);
|
||||
@@ -726,10 +870,107 @@ export async function enableOffline(account: string): Promise<void> {
|
||||
export async function disableOffline(account: string): Promise<void> {
|
||||
confirmLoss(account);
|
||||
clearHandoffs();
|
||||
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
|
||||
await wipeAccount(account);
|
||||
disabling.add(account);
|
||||
try {
|
||||
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
|
||||
await wipeAccount(account);
|
||||
// Remembered where the app would turn it on by itself.
|
||||
if (offlineDefaultOn()) writeChoice(account, { on: false });
|
||||
} finally {
|
||||
disabling.delete(account);
|
||||
}
|
||||
}
|
||||
|
||||
/** On their way off: the desktop default doesn't turn them back on meanwhile. */
|
||||
const disabling = new Set<string>();
|
||||
|
||||
/**
|
||||
* Removes everything kept for an account, unsent messages included (the user
|
||||
* confirmed), and its key, and starts again from the server if it stays on.
|
||||
*/
|
||||
export async function clearAccount(account: string): Promise<void> {
|
||||
confirmLoss(account);
|
||||
const timer = syncTimers.get(account);
|
||||
if (timer) clearTimeout(timer);
|
||||
syncTimers.delete(account);
|
||||
await nameLost(account, false);
|
||||
named.delete(account);
|
||||
try {
|
||||
await getStore().remove(account);
|
||||
} catch {
|
||||
// No store: nothing to remove. The key is gone in any case.
|
||||
}
|
||||
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;
|
||||
const { [account]: _progress, ...progress } = s.progress;
|
||||
const { [account]: _synced, ...lastSyncedAt } = s.lastSyncedAt;
|
||||
return { usage, storageFull, progress, lastSyncedAt };
|
||||
});
|
||||
await refreshQueueState();
|
||||
if (useOfflineStore.getState().enabled.includes(account)) {
|
||||
await getStore().ensureOpen(account).catch(() => {});
|
||||
scheduleSync(account, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** The user changed what an account keeps. Written first, so a reload holds to it. */
|
||||
export function chooseScope(account: string, patch: UserChoice): void {
|
||||
// Over what's in force: someone from before the local store has a choice nobody wrote down.
|
||||
writeChoice(account, { ...userChoiceFor(account), ...patch });
|
||||
// Arrowing through a list passes shorter windows on the way: those prune.
|
||||
const waiting = scopeTimers.get(account);
|
||||
if (waiting) clearTimeout(waiting);
|
||||
scopeTimers.set(account, setTimeout(() => {
|
||||
scopeTimers.delete(account);
|
||||
pushScope(account);
|
||||
}, SCOPE_SETTLE_MS));
|
||||
}
|
||||
|
||||
/** How long a choice has to stay before the store acts on it. */
|
||||
const SCOPE_SETTLE_MS = 800;
|
||||
const scopeTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
/** 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 (!visibleAccounts().includes(account) || !scopeDecided(account) || !offlineActive(account)) return;
|
||||
const scope: StoreScope = scopeForAccount(account).scope;
|
||||
void getStore().setScope(account, scope).then(() => refreshUsage()).catch(() => {});
|
||||
}
|
||||
|
||||
/** The notice about the desktop default was shown on this device. Not account data, so it outlives a sign-out. */
|
||||
const DEFAULT_ON_NOTICE_KEY = 'bulwark-offline-default-notice';
|
||||
|
||||
/**
|
||||
* The desktop app keeps mail for every account without asking, unless the
|
||||
* organisation forbids it or the user turned it off. Says so once.
|
||||
*/
|
||||
function applyDefaultOn(): void {
|
||||
if (!offlineDefaultOn() || !offlineSupported()) return;
|
||||
for (const account of signedInAccountIds()) {
|
||||
if (useOfflineStore.getState().enabled.includes(account) || disabling.has(account) || !scopeDecided(account)) continue;
|
||||
if (!scopeForAccount(account).enabled) continue;
|
||||
void enableOffline(account);
|
||||
try {
|
||||
if (localStorage.getItem(DEFAULT_ON_NOTICE_KEY)) continue;
|
||||
localStorage.setItem(DEFAULT_ON_NOTICE_KEY, '1');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
notify({ kind: 'default-on' });
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
|
||||
// ---- Unsent mail a wipe deletes ----
|
||||
|
||||
// The user agreed to lose unsent mail (the sign-out question, switching
|
||||
@@ -767,7 +1008,7 @@ const UNSENT_READ_MS = 2_000;
|
||||
*/
|
||||
export async function unsentCount(scope: string, waitMs = UNSENT_READ_MS): Promise<{ messages: number; changes: number }> {
|
||||
const shown = useOfflineStore.getState();
|
||||
const known = { messages: [...shown.outbox, ...shown.localDrafts].filter((row) => scope === 'all' || row.account === scope).length, changes: shown.pendingChanges };
|
||||
const known = { messages: [...shown.outbox, ...shown.localDrafts].filter((row) => scope === 'all' || row.account === scope).length, changes: scope === 'all' ? Object.values(shown.changesBy).reduce((sum, count) => sum + count, 0) : shown.changesBy[scope] ?? 0 };
|
||||
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);
|
||||
@@ -816,6 +1057,7 @@ export async function wipeAccount(account: string): Promise<void> {
|
||||
if (useOfflineStore.getState().enabled.includes(account)) {
|
||||
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
|
||||
}
|
||||
forgetAccountState(account);
|
||||
// Nobody keeps mail here any more: everything goes (and isn't opened
|
||||
// again, which would create it anew).
|
||||
if (useOfflineStore.getState().enabled.length === 0) {
|
||||
@@ -836,6 +1078,8 @@ export async function wipeEverything(): Promise<void> {
|
||||
for (const timer of syncTimers.values()) clearTimeout(timer);
|
||||
syncTimers.clear();
|
||||
writeEnabled([]);
|
||||
// Turned off by hand and still signed in: that stays their word.
|
||||
forgetAllAccountState(signedInAccountIds());
|
||||
getStore().stop();
|
||||
await purgeStoreFiles();
|
||||
try {
|
||||
@@ -848,7 +1092,14 @@ export async function wipeEverything(): Promise<void> {
|
||||
clearHandoffs();
|
||||
confirmed.clear();
|
||||
named.clear();
|
||||
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], localDrafts: [], usageBytes: 0, syncing: [], lastSyncedAt: {} });
|
||||
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], localDrafts: [], usageBytes: 0, syncing: [], lastSyncedAt: {} });
|
||||
}
|
||||
|
||||
/** The organisation's word on this account, from the server or the last fetch. Defaults aren't its word. */
|
||||
function scopeDecided(account: string): boolean {
|
||||
const { loaded, source, partial } = usePolicyStore.getState();
|
||||
// The public part a page gets before it's signed in has no caps and no scopes in it.
|
||||
return loaded && !partial && (source === 'server' || source === 'cache') && policyDecided(account);
|
||||
}
|
||||
|
||||
/** Whether the policy of an account is known well enough to decide on it. */
|
||||
@@ -856,7 +1107,13 @@ 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;
|
||||
if (account === auth.activeAccountId || !auth.activeAccountId) {
|
||||
const { subject, subjectPending } = usePolicyStore.getState();
|
||||
if (subjectPending) return false;
|
||||
// The active account's id is kept across a reload and its subject isn't: until
|
||||
// it's there only Everyone's values show, and a group scope may say otherwise.
|
||||
return !auth.activeAccountId || subject !== null || !(auth.isLoading || auth.client);
|
||||
}
|
||||
// Another signed-in account: its groups have to be known first (a group
|
||||
// scope may forbid it); a signed-out one is wiped with its sign-out.
|
||||
if (!auth.getClientForAccount?.(account)) return false;
|
||||
@@ -871,14 +1128,18 @@ export function enforcePolicy(): void {
|
||||
if (!policyDecided(account)) continue;
|
||||
// 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) {
|
||||
if (policyOfKept(account).noOffline) {
|
||||
// Whoever is here now wasn't signed in to a dropped account.
|
||||
const someoneElses = isEvicted(account);
|
||||
clearHandoffs();
|
||||
void wipeAccount(account);
|
||||
if (!someoneElses) notify({ kind: 'forbidden' });
|
||||
continue;
|
||||
}
|
||||
// A lowered cap prunes what's kept, and a raised one starts fetching.
|
||||
pushScope(account);
|
||||
}
|
||||
applyDefaultOn();
|
||||
}
|
||||
|
||||
// ---- Started without the server ----
|
||||
@@ -969,6 +1230,20 @@ 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_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 (health[id]?.clearedAt && health[id].clearedAt !== before[id]?.clearedAt) {
|
||||
void getStore().ensureOpen(id).catch(() => {});
|
||||
void refreshQueueState();
|
||||
void refreshUsage();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.key !== OFFLINE_ACCOUNTS_KEY && event.key !== null) return;
|
||||
const next = readEnabledFrom(event.key === null ? null : event.newValue);
|
||||
const before = useOfflineStore.getState().enabled;
|
||||
@@ -1007,6 +1282,7 @@ export function startOfflineMode(): () => void {
|
||||
// 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);
|
||||
@@ -1041,7 +1317,16 @@ export function startOfflineMode(): () => void {
|
||||
staleCheck = null;
|
||||
});
|
||||
|
||||
if (useOfflineStore.getState().enabled.length > 0) void tellServiceWorker(true);
|
||||
if (useOfflineStore.getState().enabled.length > 0) {
|
||||
void tellServiceWorker(true);
|
||||
void readPersistence().then((state) => {
|
||||
if (state === 'refused' && isStandaloneDisplayMode()) void requestPersistence();
|
||||
});
|
||||
}
|
||||
// An installed app is usually told yes where the tab was told no.
|
||||
const onInstalled = () => { if (useOfflineStore.getState().enabled.length > 0) void requestPersistence(); };
|
||||
window.addEventListener('appinstalled', onInstalled);
|
||||
cleanups.push(() => window.removeEventListener('appinstalled', onInstalled));
|
||||
void (async () => {
|
||||
await moveLegacyOutbox();
|
||||
await refreshQueueState();
|
||||
@@ -1114,7 +1399,13 @@ onFullSignOut(({ keepLocalStore }) => {
|
||||
if (!keepLocalStore) getStore().stop();
|
||||
confirmed.clear();
|
||||
named.clear();
|
||||
useOfflineStore.setState((s) => ({ enabled: keepLocalStore ? s.enabled : [], outbox: [], pendingChanges: 0, conflicts: [], localDrafts: [], usageBytes: 0, syncing: [], lastSyncedAt: {}, startedOffline: false }));
|
||||
useOfflineStore.setState((s) => ({
|
||||
enabled: keepLocalStore ? s.enabled : [],
|
||||
choices: keepLocalStore ? s.choices : {},
|
||||
health: keepLocalStore ? s.health : {},
|
||||
outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], localDrafts: [], usageBytes: 0, usage: {},
|
||||
storageFull: {}, progress: {}, syncing: [], lastSyncedAt: {}, startedOffline: false,
|
||||
}));
|
||||
});
|
||||
|
||||
// Every sign-out the user didn't agree to lose mail at (the session limits,
|
||||
|
||||
@@ -40,7 +40,7 @@ interface StoredQueue {
|
||||
}
|
||||
|
||||
/** Everything waiting for one account (or every account with it on), oldest first. */
|
||||
export async function readQueue(account?: string): Promise<{ failed: string[]; patches: number; outbox: OutboxEntry[]; conflicts: ConflictEntry[] }> {
|
||||
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] : []));
|
||||
@@ -50,6 +50,7 @@ export async function readQueue(account?: string): Promise<{ failed: string[]; p
|
||||
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 }))),
|
||||
conflicts: read.flatMap(({ id, queue }) => queue.conflicts.map((conflict) => ({
|
||||
id: conflict.id,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* What an account keeps on this device. Three sources, in this order: the
|
||||
* organisation's policy, the user's choice, the platform's default. The policy
|
||||
* only ever lowers: nothing below it can raise a cap or turn back on what it
|
||||
* 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';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
/** The windows a user picks from. Null keeps everything. */
|
||||
export const WINDOW_CHOICES: readonly (number | null)[] = [30, 90, 365, null];
|
||||
/** The largest attachment a user may keep, in MB. */
|
||||
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, 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;
|
||||
}
|
||||
|
||||
export interface EffectiveScope {
|
||||
account: string;
|
||||
/** Whether the account keeps mail here at all. */
|
||||
enabled: boolean;
|
||||
scope: StoreScope;
|
||||
/** 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;
|
||||
maxTotal: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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. */
|
||||
function cappedDays(days: number | null, cap: number): number | null {
|
||||
if (!(cap > 0)) return days;
|
||||
return days === null ? cap : Math.min(days, cap);
|
||||
}
|
||||
|
||||
function cappedBytes(bytes: number, capMb: number): number {
|
||||
return capMb > 0 ? Math.min(bytes, capMb * MB) : bytes;
|
||||
}
|
||||
|
||||
export function effectiveScope(
|
||||
account: string,
|
||||
userChoice: UserChoice | null | undefined,
|
||||
policy: Pick<DevicePolicy, 'noOffline' | 'offlineMaxDays' | 'noOfflineAttachments' | 'offlineMaxAttachmentMb' | 'offlineMaxTotalMb'>,
|
||||
defaults: PlatformDefault,
|
||||
): EffectiveScope {
|
||||
const choice = userChoice ?? {};
|
||||
const days = choice.days !== undefined ? choice.days : defaults.scope.days;
|
||||
const maxAttachment = choice.maxAttachmentBytes ?? defaults.scope.maxAttachmentBytes;
|
||||
return {
|
||||
account,
|
||||
enabled: !policy.noOffline && (defaults.forced ?? choice.on ?? defaults.on),
|
||||
scope: {
|
||||
days: cappedDays(days, policy.offlineMaxDays),
|
||||
attachments: !policy.noOfflineAttachments && (choice.attachments ?? defaults.scope.attachments),
|
||||
maxAttachmentBytes: cappedBytes(maxAttachment, policy.offlineMaxAttachmentMb),
|
||||
maxTotalBytes: cappedBytes(defaults.scope.maxTotalBytes, policy.offlineMaxTotalMb),
|
||||
orgMaxFileBytes: policy.offlineMaxAttachmentMb > 0 ? policy.offlineMaxAttachmentMb * MB : null,
|
||||
},
|
||||
locked: {
|
||||
offline: policy.noOffline,
|
||||
forced: typeof defaults.forced === 'boolean',
|
||||
days: policy.offlineMaxDays > 0,
|
||||
attachments: policy.noOfflineAttachments,
|
||||
maxAttachment: policy.offlineMaxAttachmentMb > 0,
|
||||
maxTotal: policy.offlineMaxTotalMb > 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the user chose for an account. The switch used to be the only thing
|
||||
* stored, so an account that has it on and no choice yet was turned on under
|
||||
* the old offline mode, and keeps the window it had.
|
||||
*/
|
||||
export function userChoiceFor(account: string): UserChoice | null {
|
||||
const { choices, enabled } = useOfflineStore.getState();
|
||||
const stored = choices[account];
|
||||
const on = enabled.includes(account);
|
||||
if (!stored) return on ? LEGACY_CHOICE : null;
|
||||
return on && stored.on === undefined ? { ...stored, on } : stored;
|
||||
}
|
||||
|
||||
/** The windows on offer under a cap: the ones that fit, and the cap itself when it falls between them. */
|
||||
export function windowOptions(capDays: number): (number | null)[] {
|
||||
if (!(capDays > 0)) return [...WINDOW_CHOICES];
|
||||
const fit = WINDOW_CHOICES.filter((days): days is number => days !== null && days < capDays);
|
||||
return [...fit, capDays];
|
||||
}
|
||||
|
||||
/** The attachment sizes on offer under a cap, in MB. */
|
||||
export function attachmentCapOptions(capMb: number): number[] {
|
||||
if (!(capMb > 0)) return [...ATTACHMENT_CAP_CHOICES];
|
||||
const fit = ATTACHMENT_CAP_CHOICES.filter((mb) => mb < capMb);
|
||||
return [...fit, capMb];
|
||||
}
|
||||
|
||||
export function maxAttachmentMb(scope: StoreScope): number {
|
||||
return Math.round(scope.maxAttachmentBytes / MB);
|
||||
}
|
||||
|
||||
/** A message older than the organisation's cap isn't kept, opened or not. */
|
||||
export function olderThanCap(receivedAt: unknown, capDays: number, now: number): boolean {
|
||||
if (!(capDays > 0) || typeof receivedAt !== 'string') return false;
|
||||
const at = Date.parse(receivedAt);
|
||||
return Number.isFinite(at) && at < now - capDays * 86_400_000;
|
||||
}
|
||||
+122
-5
@@ -7,11 +7,46 @@
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { DEFAULT_SCOPE, type StoreScope } from '@/lib/store/area';
|
||||
import { DEFAULT_SCOPE, type StoreScope, type StoreUsage } from '@/lib/store/area';
|
||||
import type { ConflictEntry, LocalDraftRow, 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 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 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;
|
||||
}
|
||||
|
||||
export interface AccountHealth {
|
||||
/** 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 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. */
|
||||
export interface AreaConflict {
|
||||
area: 'calendar' | 'contacts';
|
||||
account: string;
|
||||
id: number;
|
||||
createdAt: number;
|
||||
title: string;
|
||||
reason: 'refused' | 'changed' | 'gone';
|
||||
/** A delete can't be taken back, so it reads differently. */
|
||||
destroy: boolean;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export interface OutboxRow {
|
||||
id: number;
|
||||
@@ -43,11 +78,24 @@ interface OfflineState {
|
||||
pendingChanges: number;
|
||||
/** Changes the server refused, or that met a message changed on another device. */
|
||||
conflicts: ConflictEntry[];
|
||||
/** The same for calendar events and contacts. */
|
||||
areaConflicts: AreaConflict[];
|
||||
/** Changes waiting, per account: mail's, the calendar's and the contacts'. */
|
||||
changesBy: Record<string, number>;
|
||||
/** Drafts that are only on this device: on their way up, or refused by the server. */
|
||||
localDrafts: LocalDraftRow[];
|
||||
/** A local draft the user asked to open, for the mail page to pick up. */
|
||||
draftToOpen: { account: string; jmap: string; id: string } | null;
|
||||
usageBytes: number;
|
||||
usage: Record<string, StoreUsage>;
|
||||
choices: Record<string, UserChoice>;
|
||||
health: Record<string, AccountHealth>;
|
||||
/** What the browser said about keeping this site's data. */
|
||||
persist: PersistState;
|
||||
/** Accounts that couldn't write, and since when. Gone after the next good sync. */
|
||||
storageFull: Record<string, number>;
|
||||
/** How far the sync running for an account got. */
|
||||
progress: Record<string, SyncProgress>;
|
||||
/** Accounts whose mail is being brought onto the device right now. */
|
||||
syncing: string[];
|
||||
lastSyncedAt: Record<string, number>;
|
||||
@@ -63,6 +111,25 @@ function readEnabled(): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
function readRecord<T>(key: string): Record<string, T> {
|
||||
try {
|
||||
const raw = typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
|
||||
const parsed = raw ? JSON.parse(raw) : null;
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, T> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeRecord(key: string, value: Record<string, unknown>): void {
|
||||
try {
|
||||
if (Object.keys(value).length > 0) localStorage.setItem(key, JSON.stringify(value));
|
||||
else localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
export const useOfflineStore = create<OfflineState>()(() => ({
|
||||
enabled: readEnabled(),
|
||||
browserOffline: typeof navigator !== 'undefined' && navigator.onLine === false,
|
||||
@@ -73,13 +140,65 @@ export const useOfflineStore = create<OfflineState>()(() => ({
|
||||
outbox: [],
|
||||
pendingChanges: 0,
|
||||
conflicts: [],
|
||||
areaConflicts: [],
|
||||
changesBy: {},
|
||||
localDrafts: [],
|
||||
draftToOpen: null,
|
||||
usageBytes: 0,
|
||||
usage: {},
|
||||
choices: readRecord<UserChoice>(OFFLINE_SCOPE_KEY),
|
||||
health: readRecord<AccountHealth>(OFFLINE_HEALTH_KEY),
|
||||
persist: 'unknown',
|
||||
storageFull: {},
|
||||
progress: {},
|
||||
syncing: [],
|
||||
lastSyncedAt: {},
|
||||
}));
|
||||
|
||||
export function writeChoice(account: string, patch: UserChoice): void {
|
||||
const choices = { ...useOfflineStore.getState().choices, [account]: { ...useOfflineStore.getState().choices[account], ...patch } };
|
||||
useOfflineStore.setState({ choices });
|
||||
writeRecord(OFFLINE_SCOPE_KEY, choices);
|
||||
}
|
||||
|
||||
export function forgetAccountState(account: string): void {
|
||||
const { choices, health, usage, storageFull, progress } = useOfflineStore.getState();
|
||||
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_SCOPE_KEY, next.choices);
|
||||
writeRecord(OFFLINE_HEALTH_KEY, next.health);
|
||||
}
|
||||
|
||||
export function writeHealth(account: string, patch: AccountHealth): void {
|
||||
const { health } = useOfflineStore.getState();
|
||||
const current = health[account];
|
||||
if (current && Object.entries(patch).every(([key, value]) => current[key as keyof AccountHealth] === value)) return;
|
||||
const next = { ...health, [account]: { ...current, ...patch } };
|
||||
useOfflineStore.setState({ health: next });
|
||||
writeRecord(OFFLINE_HEALTH_KEY, next);
|
||||
}
|
||||
|
||||
/** The state of other tabs, after a storage event. */
|
||||
export function readStoredChoices(): Record<string, UserChoice> {
|
||||
return readRecord<UserChoice>(OFFLINE_SCOPE_KEY);
|
||||
}
|
||||
|
||||
export function readStoredHealth(): Record<string, AccountHealth> {
|
||||
return readRecord<AccountHealth>(OFFLINE_HEALTH_KEY);
|
||||
}
|
||||
|
||||
/** Nothing is kept for anyone any more. Those still signed in stay off if they turned it off. */
|
||||
export function forgetAllAccountState(signedIn: readonly string[] = []): void {
|
||||
const off: Record<string, UserChoice> = {};
|
||||
for (const [account, choice] of Object.entries(useOfflineStore.getState().choices)) {
|
||||
if (choice.on === false && signedIn.includes(account)) off[account] = { on: false };
|
||||
}
|
||||
useOfflineStore.setState({ choices: off, health: {}, usage: {}, storageFull: {}, progress: {}, areaConflicts: [], changesBy: {} });
|
||||
writeRecord(OFFLINE_SCOPE_KEY, off);
|
||||
writeRecord(OFFLINE_HEALTH_KEY, {});
|
||||
}
|
||||
|
||||
export function writeEnabled(enabled: string[]): void {
|
||||
useOfflineStore.setState({ enabled });
|
||||
try {
|
||||
@@ -90,12 +209,10 @@ export function writeEnabled(enabled: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** localStorage: what each account chose to keep offline, on top of the defaults. */
|
||||
export const OFFLINE_SCOPE_KEY = 'bulwark-offline-scope';
|
||||
|
||||
/** 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 chosen = (JSON.parse(localStorage.getItem(OFFLINE_SCOPE_KEY) ?? '{}') as Record<string, Partial<StoreScope>>)[account];
|
||||
const { on: _on, ...chosen } = (JSON.parse(localStorage.getItem(OFFLINE_SCOPE_KEY) ?? '{}') as Record<string, UserChoice>)[account] ?? {};
|
||||
return { ...DEFAULT_SCOPE, ...chosen };
|
||||
} catch {
|
||||
return DEFAULT_SCOPE;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* The browser's side of keeping mail: whether it promised not to clear it, and
|
||||
* whether it did anyway. Nothing here holds account content.
|
||||
*/
|
||||
|
||||
import { isStandaloneDisplayMode } from '@/lib/standalone-display';
|
||||
import { useOfflineStore } from './state';
|
||||
|
||||
export type PersistState = 'unknown' | 'granted' | 'refused' | 'unsupported';
|
||||
|
||||
/**
|
||||
* Asks the browser not to clear this site's data when space runs low, and
|
||||
* remembers the answer for the settings page. Chromium decides by itself and
|
||||
* may say no until the app is installed; Firefox asks the user.
|
||||
*/
|
||||
export async function requestPersistence(): Promise<PersistState> {
|
||||
let state: PersistState = 'unsupported';
|
||||
try {
|
||||
if (typeof navigator.storage?.persist === 'function') state = (await navigator.storage.persist()) ? 'granted' : 'refused';
|
||||
} catch {
|
||||
state = 'refused';
|
||||
}
|
||||
useOfflineStore.setState({ persist: state });
|
||||
return state;
|
||||
}
|
||||
|
||||
/** What the browser last said, without asking. */
|
||||
export async function readPersistence(): Promise<PersistState> {
|
||||
let state: PersistState = 'unsupported';
|
||||
try {
|
||||
if (typeof navigator.storage?.persisted === 'function') state = (await navigator.storage.persisted()) ? 'granted' : 'refused';
|
||||
} catch {
|
||||
state = 'unknown';
|
||||
}
|
||||
useOfflineStore.setState({ persist: state });
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safari deletes a site's data after 7 days without a visit, unless the site is
|
||||
* on the home screen or in the Dock. On iPhone and iPad every browser runs on
|
||||
* the same engine, so Chrome and Firefox there count too. Chromium on a Mac or
|
||||
* Windows, and the desktop app, say "Chrome" in the user agent.
|
||||
*/
|
||||
export function safariMayDelete(userAgent: string = typeof navigator === 'undefined' ? '' : navigator.userAgent, installed: boolean = isStandaloneDisplayMode()): boolean {
|
||||
if (installed) return false;
|
||||
if (!/AppleWebKit/.test(userAgent) || !/Version\/|CriOS|FxiOS|EdgiOS|OPiOS/.test(userAgent)) return false;
|
||||
if (/iPhone|iPad|iPod/.test(userAgent)) return true;
|
||||
return /Macintosh/.test(userAgent) && /Safari\//.test(userAgent) && !/Chrome|Chromium|Edg\/|OPR\//.test(userAgent);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* How far a first sync got, and how long it may still take. The store reports
|
||||
* batches of a phase: messages listed, then threads, then bodies, then files.
|
||||
*/
|
||||
|
||||
export interface SyncProgress {
|
||||
area: string;
|
||||
phase: string;
|
||||
done: number;
|
||||
total: number | null;
|
||||
/** When this phase started here, and how far it was then. */
|
||||
startedAt: number;
|
||||
startedDone: number;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listing a message is about ten times as fast as fetching its body and
|
||||
* indexing it (bench rig, 50,000 messages: 35 s to list, 400 s in all). So
|
||||
* while listing, the time left includes the bodies that come after it.
|
||||
*/
|
||||
const BODY_COST_PER_LIST_ITEM = 10;
|
||||
/** An estimate from less than this jumps about too much to show. */
|
||||
const MIN_SAMPLE_MS = 5_000;
|
||||
/** A pause this long (asleep, offline) says nothing about the rate: the sample starts again. */
|
||||
const SAMPLE_GAP_MS = 60_000;
|
||||
/** Too small to be worth a progress line: new mail arriving in a normal pass. */
|
||||
const MIN_ITEMS_SHOWN = 50;
|
||||
|
||||
export function nextProgress(
|
||||
previous: SyncProgress | undefined,
|
||||
event: { area: string; phase: string; done: number; total: number | null },
|
||||
now: number,
|
||||
): SyncProgress {
|
||||
const samePhase = previous?.area === event.area && previous.phase === event.phase && event.done >= previous.done && now - previous.at < SAMPLE_GAP_MS;
|
||||
return {
|
||||
area: event.area,
|
||||
phase: event.phase,
|
||||
done: event.done,
|
||||
total: event.total,
|
||||
startedAt: samePhase ? previous.startedAt : now,
|
||||
startedDone: samePhase ? previous.startedDone : event.done,
|
||||
at: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function progressWorthShowing(progress: SyncProgress | undefined): progress is SyncProgress {
|
||||
return !!progress && (progress.total === null || progress.total >= MIN_ITEMS_SHOWN) && progress.done >= 0;
|
||||
}
|
||||
|
||||
/** Minutes left, rounded up; null while there's nothing sensible to say. */
|
||||
export function minutesLeft(progress: SyncProgress): number | null {
|
||||
const { total, done, startedAt, startedDone, at } = progress;
|
||||
if (total === null || total <= done) return null;
|
||||
const elapsed = at - startedAt;
|
||||
const moved = done - startedDone;
|
||||
if (elapsed < MIN_SAMPLE_MS || moved <= 0) return null;
|
||||
const perItem = elapsed / moved;
|
||||
let left = (total - done) * perItem;
|
||||
if (progress.phase === 'list') left += total * perItem * BODY_COST_PER_LIST_ITEM;
|
||||
return Math.max(1, Math.ceil(left / 60_000));
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type { HostFeaturesView, InstalledPlugin } from '../plugin-types';
|
||||
import type { OrganisationPolicy } from '@/lib/admin/organisation-policy';
|
||||
import { assistantFeatures } from './assistant';
|
||||
import { keystoreFeature } from './host-api-keystore';
|
||||
import { pluginHasPermission } from './permissions';
|
||||
|
||||
/** The external-mail-warning plugin, retired in favour of organisation.externalMail. */
|
||||
export const EXTERNAL_MAIL_WARNING_PLUGIN_ID = 'external-mail-warning';
|
||||
@@ -34,6 +35,7 @@ export function snapshotHostFeatures(plugin?: InstalledPlugin): HostFeaturesView
|
||||
// The AI assistant's host APIs this webmail offers (lib/plugin-sandbox/assistant).
|
||||
...assistantFeatures(plugin),
|
||||
...keystoreFeature(plugin),
|
||||
...(plugin && pluginHasPermission(plugin, 'email:render-takeover') ? { blockExternalContent: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -986,6 +986,12 @@ export interface HostFeaturesView extends AssistantHostFeatures {
|
||||
* Absent otherwise. See `api.keystore`.
|
||||
*/
|
||||
keystore?: boolean;
|
||||
/**
|
||||
* onRenderEmailBody may return `blockExternalContent: true` to keep external content
|
||||
* off for that body, whatever the settings and trusted senders allow. Per plugin, for
|
||||
* those with `email:render-takeover`. Absent on webmails that ignore the field.
|
||||
*/
|
||||
blockExternalContent?: boolean;
|
||||
}
|
||||
|
||||
/** One setting `api.app.searchSettings` found, in the user's language. */
|
||||
|
||||
@@ -39,9 +39,12 @@ const SIGNED_OUT_STORAGE_KEYS = [
|
||||
// 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-scope',
|
||||
'bulwark-offline-health',
|
||||
];
|
||||
|
||||
const LOCAL_STORE_KEYS = new Set(['bulwark-offline-accounts', 'bulwark-offline-evicted', 'bulwark-store-opened']);
|
||||
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 };
|
||||
|
||||
@@ -22,7 +22,7 @@ function setup(version: StalwartVersion = '1.0', server = new FakeCalendarJmap(v
|
||||
session,
|
||||
transport: server,
|
||||
areas: [calendarArea],
|
||||
scope: DEFAULT_SCOPE,
|
||||
scope: { ...DEFAULT_SCOPE, days: null },
|
||||
emit: (event) => events.push(event),
|
||||
now: () => NOW,
|
||||
sleep: async () => {},
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* The real JMAPClient reading the store's answers: what the client's own code
|
||||
* makes of them is the contract, not the store's idea of the shape.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { JMAPClient } from '@/lib/jmap/client';
|
||||
import { setOfflineHooks, type OfflineHooks } from '@/lib/jmap/offline-hooks';
|
||||
import { partialAccounts } from '@/lib/jmap/partial-accounts';
|
||||
import type { AddressBook, ContactCard } from '@/lib/jmap/types';
|
||||
import { DEFAULT_SCOPE } from '../area';
|
||||
import { contactsArea } from '../contacts';
|
||||
import { AccountEngine } from '../engine/account-engine';
|
||||
import { JMAP_ACCOUNT } from './helpers/fake-jmap';
|
||||
import { CONTACTS_SESSION, FakeContacts, SHARED_ACCOUNT } from './helpers/fake-contacts';
|
||||
import { openMemoryStore } from './helpers/node-adapter';
|
||||
|
||||
afterEach(() => setOfflineHooks(null));
|
||||
|
||||
async function offlineClient(server: FakeContacts): Promise<{ client: JMAPClient; engine: AccountEngine }> {
|
||||
const engine = new AccountEngine({
|
||||
account: 'a@fake.test',
|
||||
db: openMemoryStore(),
|
||||
session: CONTACTS_SESSION,
|
||||
transport: server,
|
||||
areas: [contactsArea],
|
||||
scope: DEFAULT_SCOPE,
|
||||
sleep: async () => {},
|
||||
});
|
||||
await engine.requestSync();
|
||||
engine.setOnline(false);
|
||||
const hooks: Partial<OfflineHooks> = {
|
||||
skipNetwork: () => true,
|
||||
answer: (_client, calls) => engine.answer(calls),
|
||||
record: () => {},
|
||||
noteNetwork: () => {},
|
||||
};
|
||||
setOfflineHooks(hooks as OfflineHooks);
|
||||
const client = JMAPClient.fromOfflineSession('https://fake.test', 'a@example.org', {
|
||||
...CONTACTS_SESSION,
|
||||
capabilities: { ...CONTACTS_SESSION.capabilities, 'urn:ietf:params:jmap:core': { maxObjectsInGet: 2 } },
|
||||
});
|
||||
return { client, engine };
|
||||
}
|
||||
|
||||
function seeded(): FakeContacts {
|
||||
const server = new FakeContacts();
|
||||
for (const full of ['Anna Bauer', 'Bernd Graf', 'Carla Müller']) server.addCard({ name: { full } });
|
||||
server.addBook({ id: 'teambook', name: 'Team' }, SHARED_ACCOUNT);
|
||||
server.addCard({ id: 'c1', name: { full: 'Dora Team' }, addressBookIds: { teambook: true } }, SHARED_ACCOUNT);
|
||||
return server;
|
||||
}
|
||||
|
||||
describe('the client over the store', () => {
|
||||
it('reports a store that has nothing yet as a failure, not as no contacts', async () => {
|
||||
const engine = new AccountEngine({
|
||||
account: 'a@fake.test', db: openMemoryStore(), session: CONTACTS_SESSION, transport: new FakeContacts(),
|
||||
areas: [contactsArea], scope: DEFAULT_SCOPE, sleep: async () => {},
|
||||
});
|
||||
// Answered per call, with one more call the store can answer: the error isn't hidden by a null answer.
|
||||
const perCall: Partial<OfflineHooks> = {
|
||||
skipNetwork: () => true,
|
||||
answer: async (_client, calls) => (await engine.answer(calls)) ?? {
|
||||
methodResponses: calls.map(([, , id]) => ['error', { type: 'serverUnavailable' }, id]),
|
||||
},
|
||||
record: () => {},
|
||||
noteNetwork: () => {},
|
||||
};
|
||||
setOfflineHooks(perCall as OfflineHooks);
|
||||
const client = JMAPClient.fromOfflineSession('https://fake.test', 'a@example.org', CONTACTS_SESSION);
|
||||
await expect(client.getAllContacts()).rejects.toThrow();
|
||||
await expect(client.getAllAddressBooks()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('lists all contacts and books across accounts, paging in its own batch size', async () => {
|
||||
const { client } = await offlineClient(seeded());
|
||||
const contacts = await client.getAllContacts();
|
||||
expect(contacts.map((c) => c.name?.full).sort()).toEqual(['Anna Bauer', 'Bernd Graf', 'Carla Müller', 'Dora Team']);
|
||||
const shared = contacts.find((c) => c.name?.full === 'Dora Team');
|
||||
expect(shared).toMatchObject({ id: `${SHARED_ACCOUNT}:c1`, originalId: 'c1', isShared: true });
|
||||
const books = await client.getAllAddressBooks();
|
||||
expect(books.map((b) => b.id).sort()).toEqual(['book1', `${SHARED_ACCOUNT}:teambook`]);
|
||||
});
|
||||
|
||||
it('says which account failed, with what the others gave', async () => {
|
||||
const { client, engine } = await offlineClient(seeded());
|
||||
const failPrimary: Partial<OfflineHooks> = {
|
||||
skipNetwork: () => true,
|
||||
answer: async (_client, calls) => (calls[0][1].accountId === JMAP_ACCOUNT
|
||||
? { methodResponses: calls.map(([, , id]) => ['error', { type: 'serverUnavailable' }, id]) }
|
||||
: engine.answer(calls)),
|
||||
record: () => {},
|
||||
noteNetwork: () => {},
|
||||
};
|
||||
setOfflineHooks(failPrimary as OfflineHooks);
|
||||
const contacts = await client.getAllContacts().catch((error: unknown) => error);
|
||||
const partial = partialAccounts<ContactCard>(contacts);
|
||||
expect(partial?.failedAccountIds).toEqual([JMAP_ACCOUNT]);
|
||||
expect(partial?.primaryAccountId).toBe(JMAP_ACCOUNT);
|
||||
expect(partial?.items.map((c) => c.name?.full)).toEqual(['Dora Team']);
|
||||
const books = partialAccounts<AddressBook>(await client.getAllAddressBooks().catch((error: unknown) => error));
|
||||
expect(books?.failedAccountIds).toEqual([JMAP_ACCOUNT]);
|
||||
expect(books?.items.map((b) => b.originalId)).toEqual(['teambook']);
|
||||
});
|
||||
|
||||
it('searches with its own filter', async () => {
|
||||
const { client } = await offlineClient(seeded());
|
||||
expect((await client.searchContacts('müll')).map((c) => c.name?.full)).toEqual(['Carla Müller']);
|
||||
expect((await client.searchContacts('team')).map((c) => c.originalId)).toEqual(['c1']);
|
||||
});
|
||||
|
||||
it('creates, edits and deletes through its own methods with the stand-in id', async () => {
|
||||
const { client, engine } = await offlineClient(seeded());
|
||||
const created = await client.createContact({ name: { full: 'Neue Person' }, emails: { e: { address: 'neu@example.org' } } });
|
||||
expect(created.id).toBeTruthy();
|
||||
expect(created.addressBookIds).toEqual({ book1: true });
|
||||
await client.updateContact(created.id, { name: { full: 'Neue Person B' } });
|
||||
expect((await client.getContact(created.id))?.name?.full).toBe('Neue Person B');
|
||||
expect((await client.searchContacts('person b')).map((c) => c.id)).toEqual([created.id]);
|
||||
await client.deleteContact(created.id);
|
||||
expect(await client.getContact(created.id)).toBeNull();
|
||||
expect(await engine.areaCall('contacts.pending', undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('throws the server-style error for an edit to a card it does not have', async () => {
|
||||
const { client } = await offlineClient(seeded());
|
||||
await expect(client.updateContact('nope', { name: { full: 'X' } })).rejects.toThrow();
|
||||
await expect(client.deleteContact('nope')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('reads an account the store has as the primary contacts account', async () => {
|
||||
const { client } = await offlineClient(seeded());
|
||||
expect(client.getContactsAccountId()).toBe(JMAP_ACCOUNT);
|
||||
expect((await client.getContacts('book1')).length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* The real JMAPClient listing files from the store, in each way a server says
|
||||
* what a node is, plus what it does with a write.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { JMAPClient } from '@/lib/jmap/client';
|
||||
import { fileNodeKind } from '@/lib/jmap/filenode-kind';
|
||||
import { setOfflineHooks, type OfflineHooks } from '@/lib/jmap/offline-hooks';
|
||||
import { DEFAULT_SCOPE } from '../area';
|
||||
import { AccountEngine } from '../engine/account-engine';
|
||||
import { filesArea } from '../files';
|
||||
import { FakeFiles, FILES, FILES_SESSION, SHARED_FILES_ACCOUNT, type FakeFilesOptions } from './helpers/fake-files';
|
||||
import { openMemoryStore } from './helpers/node-adapter';
|
||||
|
||||
afterEach(() => setOfflineHooks(null));
|
||||
|
||||
async function offlineClient(server: FakeFiles): Promise<JMAPClient> {
|
||||
const engine = new AccountEngine({
|
||||
account: 'a@fake.test',
|
||||
db: openMemoryStore(),
|
||||
session: FILES_SESSION,
|
||||
transport: server,
|
||||
areas: [filesArea],
|
||||
scope: DEFAULT_SCOPE,
|
||||
sleep: async () => {},
|
||||
});
|
||||
await engine.requestSync();
|
||||
engine.setOnline(false);
|
||||
setOfflineHooks({
|
||||
skipNetwork: () => true,
|
||||
answer: (_client, calls) => engine.answer(calls),
|
||||
record: () => {},
|
||||
noteNetwork: () => {},
|
||||
} as Partial<OfflineHooks> as OfflineHooks);
|
||||
return JMAPClient.fromOfflineSession('https://fake.test', 'a@example.org', {
|
||||
...FILES_SESSION,
|
||||
capabilities: { ...FILES_SESSION.capabilities, 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500 } },
|
||||
});
|
||||
}
|
||||
|
||||
describe('file listing offline', () => {
|
||||
it.each<[string, FakeFilesOptions]>([
|
||||
['nodeType (0.16.6 and later, 1.0 as tested)', { style: 'nodeType' }],
|
||||
['blobId null only (0.16.0-5)', { style: 'blob' }],
|
||||
['@type and mediaType (1.0.0)', { style: 'draft' }],
|
||||
])('tells folders from files: %s', async (_label, options) => {
|
||||
const server = new FakeFiles(options);
|
||||
const folder = server.addFolder('Docs');
|
||||
server.addFile('note.txt', folder.id);
|
||||
const client = await offlineClient(server);
|
||||
expect(client.supportsFiles()).toBe(true);
|
||||
|
||||
const nodes = await client.listAllFileNodes();
|
||||
const byName = new Map(nodes.map((n) => [n.name, n]));
|
||||
expect(fileNodeKind(byName.get('Docs') as never)).toBe('directory');
|
||||
expect(fileNodeKind(byName.get('note.txt') as never)).toBe('file');
|
||||
expect(byName.get('note.txt')).toMatchObject({ parentId: folder.id, type: 'text/plain', size: 10 });
|
||||
expect((await client.listFileNodes(folder.id)).map((n) => n.name)).toEqual(['note.txt']);
|
||||
expect((await client.listFileNodes(null)).map((n) => n.name)).toEqual(['Docs']);
|
||||
});
|
||||
|
||||
it('lists an account larger than one /get, ending its paging cleanly', async () => {
|
||||
const server = new FakeFiles({ getAll: 'refuse' });
|
||||
const folder = server.addFolder('Docs');
|
||||
for (let i = 0; i < 700; i += 1) server.addFile(`f${i}`, folder.id);
|
||||
const client = await offlineClient(server);
|
||||
expect(await client.listAllFileNodes()).toHaveLength(701);
|
||||
});
|
||||
|
||||
it('lists shared accounts with namespaced ids', async () => {
|
||||
const server = new FakeFiles();
|
||||
server.addFolder('Mine');
|
||||
const shared = server.addFolder('Theirs', null, undefined, SHARED_FILES_ACCOUNT);
|
||||
const client = await offlineClient(server);
|
||||
const nodes = await client.listAllFileNodesAcrossAccounts();
|
||||
expect(nodes.find((n) => n.name === 'Theirs')).toMatchObject({ id: `${SHARED_FILES_ACCOUNT}:${shared.id}`, isShared: true });
|
||||
expect(nodes.find((n) => n.name === 'Mine')).toMatchObject({ isShared: false });
|
||||
});
|
||||
|
||||
it('fails a write with an error rather than losing it', async () => {
|
||||
const server = new FakeFiles();
|
||||
const client = await offlineClient(server);
|
||||
await expect(client.createFileDirectory('New', null)).rejects.toThrow();
|
||||
expect(FILES in FILES_SESSION.capabilities).toBe(true);
|
||||
expect(server.calls.some(([n]) => n === 'FileNode/set')).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,630 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_SCOPE, type StoreEvent, type StoreScope } from '../area';
|
||||
import { AccountEngine } from '../engine/account-engine';
|
||||
import { TransportError } from '../engine/transport';
|
||||
import { fileBlob, FILE_CACHE_CAP_BYTES, filesArea } from '../files';
|
||||
import type { StorageAdapter } from '../storage';
|
||||
import { JMAP_ACCOUNT } from './helpers/fake-jmap';
|
||||
import { FakeFiles, FILES_SESSION, SHARED_FILES_ACCOUNT } from './helpers/fake-files';
|
||||
import { openMemoryStore } from './helpers/node-adapter';
|
||||
|
||||
const NOW = Date.UTC(2026, 10, 3);
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- answers are shaped like the server's */
|
||||
|
||||
interface Rig {
|
||||
db: StorageAdapter;
|
||||
engine: AccountEngine;
|
||||
clock: { at: number };
|
||||
events: StoreEvent[];
|
||||
}
|
||||
|
||||
function rigFor(server: FakeFiles, scope: Partial<StoreScope> = {}): Rig {
|
||||
const db = openMemoryStore();
|
||||
const clock = { at: NOW };
|
||||
const events: StoreEvent[] = [];
|
||||
const engine = new AccountEngine({
|
||||
account: 'a@fake.test',
|
||||
db,
|
||||
session: FILES_SESSION,
|
||||
transport: server,
|
||||
areas: [filesArea],
|
||||
scope: { ...DEFAULT_SCOPE, ...scope },
|
||||
emit: (event) => events.push(event),
|
||||
now: () => clock.at,
|
||||
sleep: async () => {},
|
||||
});
|
||||
return { db, engine, clock, events };
|
||||
}
|
||||
|
||||
async function synced(server: FakeFiles, scope: Partial<StoreScope> = {}): Promise<Rig> {
|
||||
const rig = rigFor(server, scope);
|
||||
await rig.engine.requestSync();
|
||||
return rig;
|
||||
}
|
||||
|
||||
async function call(engine: AccountEngine, name: string, args: Record<string, unknown> = {}): Promise<any> {
|
||||
const response = await engine.answer([[name, { accountId: JMAP_ACCOUNT, ...args }, '0']]);
|
||||
if (!response) throw new Error('nothing answered');
|
||||
const [answered, result] = response.methodResponses[0];
|
||||
if (answered === 'error') throw new Error(result.type);
|
||||
return result;
|
||||
}
|
||||
|
||||
const ref = (id: string, jmap = JMAP_ACCOUNT) => ({ jmap, id });
|
||||
|
||||
function names(rows: Array<{ name?: unknown }>): unknown[] {
|
||||
return rows.map((row) => row.name).sort();
|
||||
}
|
||||
|
||||
describe('file metadata sync', () => {
|
||||
it('stores small accounts from one FileNode/get', async () => {
|
||||
const server = new FakeFiles();
|
||||
const folder = server.addFolder('Docs');
|
||||
server.addFile('a.txt', folder.id);
|
||||
const { engine } = await synced(server);
|
||||
expect(server.calls.filter(([n]) => n === 'FileNode/query')).toHaveLength(0);
|
||||
expect(names((await call(engine, 'FileNode/get', { ids: null })).list)).toEqual(['Docs', 'a.txt']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['0.16 cuts the list at 500', { getAll: 'cut' as const }],
|
||||
['1.0 refuses a list over 500', { getAll: 'refuse' as const }],
|
||||
])('pages past the /get limit: %s', async (_label, options) => {
|
||||
const server = new FakeFiles(options);
|
||||
const folder = server.addFolder('Docs');
|
||||
for (let i = 0; i < 1234; i += 1) server.addFile(`f${i}.txt`, folder.id);
|
||||
const { db } = await synced(server);
|
||||
expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_node')?.n)).toBe(1235);
|
||||
for (const [name, args] of server.calls) {
|
||||
if (name === 'FileNode/get' && Array.isArray(args.ids)) expect(args.ids.length).toBeLessThanOrEqual(500);
|
||||
}
|
||||
});
|
||||
|
||||
it('finds folders the query leaves out on old servers, by the parents of what it did find', async () => {
|
||||
const server = new FakeFiles({ queryOmitsFolders: true, style: 'blob' });
|
||||
for (let i = 0; i < 600; i += 1) server.addFile(`early${i}.txt`);
|
||||
const top = server.addFolder('Top');
|
||||
const inner = server.addFolder('Inner', top.id);
|
||||
server.addFile('deep.txt', inner.id);
|
||||
const { db } = await synced(server);
|
||||
const kinds = db.all<{ name: string; kind: string }>("SELECT name, kind FROM file_node WHERE name IN ('Top', 'Inner', 'deep.txt') ORDER BY name");
|
||||
expect(kinds).toEqual([{ name: 'Inner', kind: 'directory' }, { name: 'Top', kind: 'directory' }, { name: 'deep.txt', kind: 'file' }]);
|
||||
});
|
||||
|
||||
it('continues a stopped full sync from its cursor', async () => {
|
||||
const server = new FakeFiles({ getAll: 'refuse' });
|
||||
for (let i = 0; i < 6000; i += 1) server.addFile(`f${i}`);
|
||||
const rig = rigFor(server);
|
||||
let gets = 0;
|
||||
server.beforeRequest = () => {
|
||||
gets += 1;
|
||||
// State capture, the refused list, a query page of 5000 and two requests of gets: then the network goes.
|
||||
if (gets === 6) server.offline = true;
|
||||
};
|
||||
await rig.engine.requestSync();
|
||||
expect(Number(rig.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_node')?.n)).toBe(5000);
|
||||
server.offline = false;
|
||||
server.beforeRequest = null;
|
||||
server.calls.length = 0;
|
||||
await rig.engine.requestSync();
|
||||
expect(Number(rig.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_node')?.n)).toBe(6000);
|
||||
expect(server.calls.find(([n]) => n === 'FileNode/query')?.[1].position).toBe(5000);
|
||||
expect(server.calls.filter(([n, a]) => n === 'FileNode/get' && a.ids === null && a.accountId === JMAP_ACCOUNT)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('follows changes through FileNode/changes', async () => {
|
||||
const server = new FakeFiles();
|
||||
const a = server.addFile('a.txt');
|
||||
const b = server.addFile('b.txt');
|
||||
const { engine } = await synced(server);
|
||||
server.calls.length = 0;
|
||||
server.renameNode(a.id, 'renamed.txt');
|
||||
server.removeNode(b.id);
|
||||
server.addFolder('New');
|
||||
await engine.requestSync();
|
||||
expect(server.calls.some(([n]) => n === 'FileNode/changes')).toBe(true);
|
||||
expect(server.calls.some(([n, args]) => n === 'FileNode/get' && args.ids === null)).toBe(false);
|
||||
expect(names((await call(engine, 'FileNode/get', { ids: null })).list)).toEqual(['New', 'renamed.txt']);
|
||||
});
|
||||
|
||||
it('starts over when the server can no longer give changes, and keeps a pin on a file still there', async () => {
|
||||
const server = new FakeFiles();
|
||||
const gone = server.addFile('gone.txt');
|
||||
const kept = server.addFile('kept.txt');
|
||||
const { db, engine } = await synced(server);
|
||||
await engine.areaCall('files.pin', ref(kept.id));
|
||||
await engine.requestSync();
|
||||
server.removeNode(gone.id);
|
||||
server.account().coll.truncatedBelow = server.account().coll.log.length;
|
||||
await engine.requestSync();
|
||||
expect(db.all('SELECT id FROM file_node').map((r) => r.id)).toEqual([kept.id]);
|
||||
expect(db.get<{ pinned: number }>('SELECT pinned FROM file_node WHERE id = ?', [kept.id])?.pinned).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps accounts apart, even with the same ids', async () => {
|
||||
const server = new FakeFiles();
|
||||
server.addFile('own.txt', null, 5, { id: 'same' });
|
||||
server.addFile('team.txt', null, 5, { id: 'same', blobId: 'team-blob' }, SHARED_FILES_ACCOUNT);
|
||||
const { engine } = await synced(server);
|
||||
expect(names((await call(engine, 'FileNode/get', { ids: ['same'] })).list)).toEqual(['own.txt']);
|
||||
expect(names((await call(engine, 'FileNode/get', { ids: ['same'], accountId: SHARED_FILES_ACCOUNT })).list)).toEqual(['team.txt']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file answers', () => {
|
||||
it('says so instead of an empty list when nothing was read yet', async () => {
|
||||
const { engine } = rigFor(new FakeFiles());
|
||||
expect(await engine.answer([['FileNode/get', { accountId: JMAP_ACCOUNT, ids: null }, '0']])).toBeNull();
|
||||
});
|
||||
|
||||
it('gives the last server state and only asked-for properties', async () => {
|
||||
const server = new FakeFiles();
|
||||
const file = server.addFile('a.txt');
|
||||
const { engine } = await synced(server);
|
||||
const got = await call(engine, 'FileNode/get', { ids: [file.id, 'nope'], properties: ['name', 'parentId'] });
|
||||
expect(got.state).toBe(server.account().coll.state());
|
||||
expect(got.list).toEqual([{ id: file.id, name: 'a.txt', parentId: null }]);
|
||||
expect(got.notFound).toEqual(['nope']);
|
||||
});
|
||||
|
||||
it('answers a query by parent, also at the root, which the real server refuses to take', async () => {
|
||||
const server = new FakeFiles();
|
||||
const folder = server.addFolder('Docs');
|
||||
const inside = server.addFile('in.txt', folder.id);
|
||||
const root = server.addFile('root.txt');
|
||||
const { engine } = await synced(server);
|
||||
expect((await call(engine, 'FileNode/query', { filter: { parentId: folder.id } })).ids).toEqual([inside.id]);
|
||||
expect((await call(engine, 'FileNode/query', { filter: { parentId: null } })).ids).toEqual([folder.id, root.id]);
|
||||
});
|
||||
|
||||
it('pages and sorts a query', async () => {
|
||||
const server = new FakeFiles();
|
||||
server.addFile('b', null, 30);
|
||||
server.addFile('a', null, 10);
|
||||
server.addFile('c', null, 20);
|
||||
const { engine } = await synced(server);
|
||||
const page = await call(engine, 'FileNode/query', { filter: {}, position: 1, limit: 1, calculateTotal: true });
|
||||
expect(page).toMatchObject({ position: 1, total: 3, ids: [expect.any(String)] });
|
||||
const bySize = await call(engine, 'FileNode/query', { filter: {}, sort: [{ property: 'size', isAscending: false }] });
|
||||
const list = (await call(engine, 'FileNode/get', { ids: bySize.ids })).list;
|
||||
expect(list.map((n: { name: string }) => n.name)).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
it('refuses what it cannot read, for that call only', async () => {
|
||||
const server = new FakeFiles();
|
||||
server.addFile('a');
|
||||
const { engine } = await synced(server);
|
||||
const response = await engine.answer([
|
||||
['FileNode/query', { accountId: JMAP_ACCOUNT, filter: { hasFoo: true } }, '0'],
|
||||
['FileNode/get', { accountId: JMAP_ACCOUNT, ids: null }, '1'],
|
||||
]);
|
||||
expect(response?.methodResponses[0][1].type).toBe('unsupportedFilter');
|
||||
expect(response?.methodResponses[1][0]).toBe('FileNode/get');
|
||||
});
|
||||
|
||||
it('answers FileNode/set with a clear per-call error, not silence', async () => {
|
||||
const server = new FakeFiles();
|
||||
server.addFile('a');
|
||||
const { engine } = await synced(server);
|
||||
const response = await engine.answer([
|
||||
['FileNode/set', { accountId: JMAP_ACCOUNT, create: { n0: { name: 'x' } } }, '0'],
|
||||
['FileNode/get', { accountId: JMAP_ACCOUNT, ids: null }, '1'],
|
||||
]);
|
||||
expect(response?.methodResponses[0]).toEqual(['error', { type: 'serverUnavailable', description: 'FileNode/set needs the server' }, '0']);
|
||||
expect(response?.methodResponses[1][0]).toBe('FileNode/get');
|
||||
expect(server.calls.some(([n]) => n === 'FileNode/set')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pinned files', () => {
|
||||
it('fetches a pinned file and gives it back byte for byte, past the chunk size', async () => {
|
||||
const server = new FakeFiles();
|
||||
const content = new Uint8Array(3 * 1024 * 1024 + 17).map((_, i) => i % 251);
|
||||
const file = server.addFile('big.bin', null, content);
|
||||
const { db, engine } = await synced(server);
|
||||
expect(server.downloads).toEqual([]);
|
||||
|
||||
expect(await engine.areaCall('files.pin', ref(file.id))).toEqual({ kept: false });
|
||||
await engine.requestSync();
|
||||
expect(server.downloads).toEqual([{ jmap: JMAP_ACCOUNT, blobId: file.blobId }]);
|
||||
expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(4);
|
||||
|
||||
const read = await engine.areaCall('files.read', ref(file.id)) as any;
|
||||
expect(read.name).toBe('big.bin');
|
||||
expect(read.type).toBe('text/plain');
|
||||
expect(Buffer.compare(read.data, content)).toBe(0);
|
||||
expect(Buffer.compare(fileBlob({ db }, JMAP_ACCOUNT, file.blobId as string)?.data as Uint8Array, content)).toBe(0);
|
||||
expect(await engine.areaCall('files.blob', { jmap: JMAP_ACCOUNT, blobId: file.blobId })).toMatchObject({ size: content.length });
|
||||
// Already kept: not fetched again.
|
||||
await engine.requestSync();
|
||||
expect(server.downloads).toHaveLength(1);
|
||||
expect(await engine.areaCall('files.pin', ref(file.id))).toEqual({ kept: true });
|
||||
});
|
||||
|
||||
it('names a file with a %-encoded name from 0.16 WebDAV the way the client shows it', async () => {
|
||||
const server = new FakeFiles();
|
||||
const file = server.addFile('My%20Report.pdf');
|
||||
const { engine } = await synced(server);
|
||||
await engine.areaCall('files.pin', ref(file.id));
|
||||
await engine.requestSync();
|
||||
expect((await engine.areaCall('files.read', ref(file.id)) as any).name).toBe('My Report.pdf');
|
||||
expect((await engine.areaCall('files.kept', undefined) as any).files[0].name).toBe('My Report.pdf');
|
||||
// The stored node stays as the server sent it. The client decodes it once.
|
||||
expect((await call(engine, 'FileNode/get', { ids: [file.id] })).list[0].name).toBe('My%20Report.pdf');
|
||||
});
|
||||
|
||||
it('gives nothing for a file not kept', async () => {
|
||||
const server = new FakeFiles();
|
||||
const file = server.addFile('a.txt');
|
||||
const { db, engine } = await synced(server);
|
||||
expect(await engine.areaCall('files.read', ref(file.id))).toBeNull();
|
||||
expect(fileBlob({ db }, JMAP_ACCOUNT, file.blobId as string)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to pin a folder or an unknown id', async () => {
|
||||
const server = new FakeFiles();
|
||||
const folder = server.addFolder('Docs');
|
||||
const { engine } = await synced(server);
|
||||
await expect(engine.areaCall('files.pin', ref(folder.id))).rejects.toThrow('Only files');
|
||||
await expect(engine.areaCall('files.pin', ref('nope'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('downloads one at a time', async () => {
|
||||
const server = new FakeFiles();
|
||||
const files = [server.addFile('a'), server.addFile('b'), server.addFile('c')];
|
||||
const { engine } = await synced(server);
|
||||
for (const file of files) await engine.areaCall('files.pin', ref(file.id));
|
||||
await engine.requestSync();
|
||||
expect(server.downloads).toHaveLength(3);
|
||||
expect(server.mostAtOnce).toBe(1);
|
||||
});
|
||||
|
||||
it('fetches a pinned file over the one-file limit, and skips an opened one', async () => {
|
||||
const server = new FakeFiles();
|
||||
const pinned = server.addFile('pinned.bin', null, 100);
|
||||
const opened = server.addFile('opened.bin', null, 100);
|
||||
const { engine } = await synced(server, { maxAttachmentBytes: 50 });
|
||||
await engine.areaCall('files.pin', ref(pinned.id));
|
||||
await engine.areaCall('files.noteOpened', ref(opened.id));
|
||||
await engine.requestSync();
|
||||
expect(server.downloads.map((d) => d.blobId)).toEqual([pinned.blobId]);
|
||||
const status = await engine.areaCall('files.kept', undefined) as any;
|
||||
const row = (name: string) => status.files.find((f: { name: string }) => f.name === name);
|
||||
expect(row('pinned.bin')).toMatchObject({ pinned: true, kept: true, error: null });
|
||||
expect(row('opened.bin')).toMatchObject({ pinned: false, kept: false, error: 'tooLarge' });
|
||||
});
|
||||
|
||||
it('does not take pins past the total limit, and says so', async () => {
|
||||
const server = new FakeFiles();
|
||||
const a = server.addFile('a', null, 60);
|
||||
const b = server.addFile('b', null, 60);
|
||||
const { engine, clock } = await synced(server, { maxTotalBytes: 100 });
|
||||
await engine.areaCall('files.pin', ref(a.id));
|
||||
clock.at += 1;
|
||||
await engine.areaCall('files.pin', ref(b.id));
|
||||
await engine.requestSync();
|
||||
expect(server.downloads).toHaveLength(1);
|
||||
const status = await engine.areaCall('files.kept', undefined) as any;
|
||||
expect(status.files.filter((f: { kept: boolean }) => f.kept)).toHaveLength(1);
|
||||
expect(status.files.filter((f: { error: string | null }) => f.error === 'overLimit')).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('a tightened scope, with no network', () => {
|
||||
async function keptOffline(rig: Rig) {
|
||||
rig.engine.setOnline(false);
|
||||
}
|
||||
|
||||
it('drops everything kept when files are forbidden, and the pins stay', async () => {
|
||||
const server = new FakeFiles();
|
||||
const file = server.addFile('a', null, 60);
|
||||
const rig = await synced(server);
|
||||
await rig.engine.areaCall('files.pin', ref(file.id));
|
||||
await rig.engine.requestSync();
|
||||
await keptOffline(rig);
|
||||
rig.engine.setScope({ ...DEFAULT_SCOPE, attachments: false });
|
||||
await rig.engine.requestSync();
|
||||
expect(await rig.engine.areaCall('files.read', ref(file.id))).toBeNull();
|
||||
expect((await rig.engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ pinned: true, kept: false, error: 'notAllowed' });
|
||||
});
|
||||
|
||||
it('drops an opened file over the new size, not a pinned one', async () => {
|
||||
const server = new FakeFiles();
|
||||
const pinned = server.addFile('pinned', null, 60);
|
||||
const opened = server.addFile('opened', null, 60);
|
||||
const rig = await synced(server);
|
||||
await rig.engine.areaCall('files.pin', ref(pinned.id));
|
||||
await rig.engine.areaCall('files.noteOpened', ref(opened.id));
|
||||
await rig.engine.requestSync();
|
||||
await keptOffline(rig);
|
||||
rig.engine.setScope({ ...DEFAULT_SCOPE, maxAttachmentBytes: 10 });
|
||||
await rig.engine.requestSync();
|
||||
expect(await rig.engine.areaCall('files.read', ref(opened.id))).toBeNull();
|
||||
expect(await rig.engine.areaCall('files.read', ref(pinned.id))).not.toBeNull();
|
||||
});
|
||||
|
||||
it('holds a pin to the organisation\'s cap on one file, not to the user\'s own', async () => {
|
||||
const server = new FakeFiles();
|
||||
const pinned = server.addFile('pinned', null, 60);
|
||||
const rig = await synced(server);
|
||||
await rig.engine.areaCall('files.pin', ref(pinned.id));
|
||||
await rig.engine.requestSync();
|
||||
await keptOffline(rig);
|
||||
rig.engine.setScope({ ...DEFAULT_SCOPE, maxAttachmentBytes: 10, orgMaxFileBytes: null });
|
||||
await rig.engine.requestSync();
|
||||
expect(await rig.engine.areaCall('files.read', ref(pinned.id))).not.toBeNull();
|
||||
|
||||
rig.engine.setScope({ ...DEFAULT_SCOPE, maxAttachmentBytes: 10, orgMaxFileBytes: 10 });
|
||||
await rig.engine.requestSync();
|
||||
expect(await rig.engine.areaCall('files.read', ref(pinned.id))).toBeNull();
|
||||
expect((await rig.engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ pinned: true, kept: false, error: 'tooLarge' });
|
||||
|
||||
// Not fetched back while the cap holds; fetched back once it's lifted.
|
||||
server.downloads.length = 0;
|
||||
rig.engine.setOnline(true);
|
||||
await rig.engine.requestSync();
|
||||
expect(server.downloads).toEqual([]);
|
||||
rig.engine.setScope({ ...DEFAULT_SCOPE, orgMaxFileBytes: null });
|
||||
await rig.engine.requestSync();
|
||||
expect(await rig.engine.areaCall('files.read', ref(pinned.id))).not.toBeNull();
|
||||
});
|
||||
|
||||
it('holds even pins to a lower total, newest out first, and doesn\'t fetch them back', async () => {
|
||||
const server = new FakeFiles();
|
||||
const older = server.addFile('older', null, 60);
|
||||
const newer = server.addFile('newer', null, 60);
|
||||
const rig = await synced(server);
|
||||
await rig.engine.areaCall('files.pin', ref(older.id));
|
||||
await rig.engine.requestSync();
|
||||
rig.clock.at += 1000;
|
||||
await rig.engine.areaCall('files.pin', ref(newer.id));
|
||||
await rig.engine.requestSync();
|
||||
await keptOffline(rig);
|
||||
rig.engine.setScope({ ...DEFAULT_SCOPE, maxTotalBytes: 100 });
|
||||
await rig.engine.requestSync();
|
||||
expect(await rig.engine.areaCall('files.read', ref(older.id))).not.toBeNull();
|
||||
expect(await rig.engine.areaCall('files.read', ref(newer.id))).toBeNull();
|
||||
const status = (await rig.engine.areaCall('files.kept', undefined) as any).files;
|
||||
expect(status.find((f: { id: string }) => f.id === newer.id).error).toBe('overLimit');
|
||||
|
||||
server.downloads.length = 0;
|
||||
rig.engine.setOnline(true);
|
||||
await rig.engine.requestSync();
|
||||
expect(server.downloads).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('counts towards the storage page, and takes its part of the total from mail', async () => {
|
||||
const server = new FakeFiles();
|
||||
const file = server.addFile('a', null, 60);
|
||||
const rig = await synced(server);
|
||||
await rig.engine.areaCall('files.pin', ref(file.id));
|
||||
await rig.engine.requestSync();
|
||||
const usage = await rig.engine.areaCall('store.usage', undefined) as { parts: Array<{ part: string; bytes: number; items: number }> };
|
||||
expect(usage.parts).toContainEqual({ part: 'files', bytes: 60, items: 1 });
|
||||
expect(filesArea.keptBytes?.({ db: rig.db })).toBe(60);
|
||||
});
|
||||
|
||||
it('keeps nothing when the scope forbids it, and drops what was kept', async () => {
|
||||
const server = new FakeFiles();
|
||||
const file = server.addFile('a');
|
||||
const rig = await synced(server);
|
||||
await rig.engine.areaCall('files.pin', ref(file.id));
|
||||
await rig.engine.requestSync();
|
||||
expect(await rig.engine.areaCall('files.read', ref(file.id))).not.toBeNull();
|
||||
|
||||
rig.engine.setScope({ ...DEFAULT_SCOPE, attachments: false });
|
||||
await rig.engine.requestSync();
|
||||
expect(await rig.engine.areaCall('files.read', ref(file.id))).toBeNull();
|
||||
expect(Number(rig.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(0);
|
||||
expect((await rig.engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ pinned: true, kept: false, error: 'notAllowed' });
|
||||
|
||||
rig.engine.setScope(DEFAULT_SCOPE);
|
||||
await rig.engine.requestSync();
|
||||
expect(await rig.engine.areaCall('files.read', ref(file.id))).not.toBeNull();
|
||||
});
|
||||
|
||||
it('goes by the real size when the metadata has it wrong', async () => {
|
||||
const server = new FakeFiles();
|
||||
const opened = server.addFile('opened', null, new Uint8Array(100), { size: 1 });
|
||||
const { engine } = await synced(server, { maxAttachmentBytes: 50 });
|
||||
await engine.areaCall('files.noteOpened', ref(opened.id));
|
||||
await engine.requestSync();
|
||||
expect(server.downloads).toHaveLength(1);
|
||||
expect(await engine.areaCall('files.read', ref(opened.id))).toBeNull();
|
||||
expect((await engine.areaCall('files.kept', undefined) as any).files[0].error).toBe('tooLarge');
|
||||
});
|
||||
|
||||
it('does not store a file unpinned while it was downloading', async () => {
|
||||
const server = new FakeFiles();
|
||||
const file = server.addFile('a');
|
||||
const { engine } = await synced(server);
|
||||
await engine.areaCall('files.pin', ref(file.id));
|
||||
const original = server.download.bind(server);
|
||||
server.download = async (jmap, blobId) => {
|
||||
await engine.areaCall('files.unpin', ref(file.id));
|
||||
return original(jmap, blobId);
|
||||
};
|
||||
await engine.requestSync();
|
||||
expect(server.downloads).toHaveLength(1);
|
||||
expect(await engine.areaCall('files.read', ref(file.id))).toBeNull();
|
||||
});
|
||||
|
||||
it('lets a never-opened file go on unpin, and keeps an opened one as recent', async () => {
|
||||
const server = new FakeFiles();
|
||||
const never = server.addFile('never');
|
||||
const opened = server.addFile('opened');
|
||||
const { engine } = await synced(server);
|
||||
await engine.areaCall('files.pin', ref(never.id));
|
||||
await engine.areaCall('files.pin', ref(opened.id));
|
||||
await engine.areaCall('files.noteOpened', ref(opened.id));
|
||||
await engine.requestSync();
|
||||
await engine.areaCall('files.unpin', ref(never.id));
|
||||
await engine.areaCall('files.unpin', ref(opened.id));
|
||||
expect(await engine.areaCall('files.read', ref(never.id))).toBeNull();
|
||||
expect(await engine.areaCall('files.read', ref(opened.id))).not.toBeNull();
|
||||
const status = await engine.areaCall('files.kept', undefined) as any;
|
||||
expect(status.files.map((f: { name: string; pinned: boolean }) => [f.name, f.pinned])).toEqual([['opened', false]]);
|
||||
});
|
||||
|
||||
it('drops the old content and fetches the new one when a pinned file changes', async () => {
|
||||
const server = new FakeFiles();
|
||||
const file = server.addFile('a.txt', null, new Uint8Array([1, 2, 3]));
|
||||
const { db, engine } = await synced(server);
|
||||
await engine.areaCall('files.pin', ref(file.id));
|
||||
await engine.requestSync();
|
||||
const old = file.blobId as string;
|
||||
const fresh = server.replaceContent(file.id, new Uint8Array([9, 9]));
|
||||
await engine.requestSync();
|
||||
expect(fileBlob({ db }, JMAP_ACCOUNT, old)).toBeNull();
|
||||
expect([...(fileBlob({ db }, JMAP_ACCOUNT, fresh)?.data ?? [])]).toEqual([9, 9]);
|
||||
expect(Array.from((await engine.areaCall('files.read', ref(file.id)) as any).data)).toEqual([9, 9]);
|
||||
});
|
||||
|
||||
it('drops the content when the file is removed on the server, unless another node shares it', async () => {
|
||||
const server = new FakeFiles();
|
||||
const a = server.addFile('a', null, 5, { blobId: 'shared-blob' });
|
||||
const b = server.addFile('b', null, 5, { blobId: 'shared-blob' });
|
||||
const { db, engine } = await synced(server);
|
||||
await engine.areaCall('files.pin', ref(a.id));
|
||||
await engine.areaCall('files.pin', ref(b.id));
|
||||
await engine.requestSync();
|
||||
expect(server.downloads).toHaveLength(1);
|
||||
server.removeNode(a.id);
|
||||
await engine.requestSync();
|
||||
expect(fileBlob({ db }, JMAP_ACCOUNT, 'shared-blob')).not.toBeNull();
|
||||
server.removeNode(b.id);
|
||||
await engine.requestSync();
|
||||
expect(fileBlob({ db }, JMAP_ACCOUNT, 'shared-blob')).toBeNull();
|
||||
expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(0);
|
||||
});
|
||||
|
||||
it('carries on past one bad file, and hands a network failure to the engine', async () => {
|
||||
const server = new FakeFiles();
|
||||
const bad = server.addFile('bad');
|
||||
const good = server.addFile('good');
|
||||
server.blobs.delete(bad.blobId as string);
|
||||
const { engine, clock } = await synced(server);
|
||||
await engine.areaCall('files.pin', ref(bad.id));
|
||||
clock.at += 1;
|
||||
await engine.areaCall('files.pin', ref(good.id));
|
||||
await engine.requestSync();
|
||||
expect(await engine.areaCall('files.read', ref(good.id))).not.toBeNull();
|
||||
const status = await engine.areaCall('files.kept', undefined) as any;
|
||||
expect(status.files.find((f: { name: string }) => f.name === 'bad').error).toBe('download');
|
||||
|
||||
});
|
||||
|
||||
it('hands a network failure to the engine, which ends the pass, and goes on next time', async () => {
|
||||
const server = new FakeFiles();
|
||||
const file = server.addFile('a');
|
||||
const rig = await synced(server);
|
||||
await rig.engine.areaCall('files.pin', ref(file.id));
|
||||
const original = server.download.bind(server);
|
||||
server.download = async () => {
|
||||
throw new TransportError('network', 'Failed to fetch');
|
||||
};
|
||||
const syncedBefore = rig.events.filter((e) => e.type === 'synced').length;
|
||||
await rig.engine.requestSync();
|
||||
expect(rig.events.some((e) => e.type === 'error')).toBe(false);
|
||||
expect(rig.events.filter((e) => e.type === 'synced').length).toBe(syncedBefore);
|
||||
expect((await rig.engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ kept: false, error: null });
|
||||
|
||||
server.download = original;
|
||||
await rig.engine.requestSync();
|
||||
expect(await rig.engine.areaCall('files.read', ref(file.id))).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('recently opened files', () => {
|
||||
it('keeps an opened file without a pin', async () => {
|
||||
const server = new FakeFiles();
|
||||
const file = server.addFile('a');
|
||||
const { engine } = await synced(server);
|
||||
expect(await engine.areaCall('files.noteOpened', ref(file.id))).toEqual({ kept: false });
|
||||
await engine.requestSync();
|
||||
expect(await engine.areaCall('files.read', ref(file.id))).not.toBeNull();
|
||||
expect(await engine.areaCall('files.noteOpened', ref(file.id))).toEqual({ kept: true });
|
||||
});
|
||||
|
||||
it('pushes out the oldest unpinned first, and never a pinned one', async () => {
|
||||
const server = new FakeFiles();
|
||||
const files = Object.fromEntries(['old', 'pinnedOldest', 'mid', 'new', 'newest'].map((name) => [name, server.addFile(name, null, 10)]));
|
||||
const rig = await synced(server, { maxTotalBytes: 30 });
|
||||
const open = async (name: string) => {
|
||||
rig.clock.at += 1000;
|
||||
await rig.engine.areaCall('files.noteOpened', ref(files[name].id));
|
||||
await rig.engine.requestSync();
|
||||
};
|
||||
await open('pinnedOldest');
|
||||
await rig.engine.areaCall('files.pin', ref(files.pinnedOldest.id));
|
||||
await open('old');
|
||||
await open('mid');
|
||||
const kept = async () => (await rig.engine.areaCall('files.kept', undefined) as any).files
|
||||
.filter((f: { kept: boolean }) => f.kept).map((f: { name: string }) => f.name).sort();
|
||||
expect(await kept()).toEqual(['mid', 'old', 'pinnedOldest']);
|
||||
|
||||
await open('new');
|
||||
expect(await kept()).toEqual(['mid', 'new', 'pinnedOldest']);
|
||||
await open('mid');
|
||||
await open('newest');
|
||||
expect(await kept()).toEqual(['mid', 'newest', 'pinnedOldest']);
|
||||
expect((await rig.engine.areaCall('files.kept', undefined) as any).bytes).toBe(30);
|
||||
|
||||
// A pinned file was never pushed out and had to come back.
|
||||
expect(server.downloads.filter((d) => d.blobId === files.pinnedOldest.blobId)).toHaveLength(1);
|
||||
// Pushed out means forgotten: the next pass doesn't fetch it straight back.
|
||||
expect(server.downloads.filter((d) => d.blobId === files.old.blobId)).toHaveLength(1);
|
||||
const count = server.downloads.length;
|
||||
await rig.engine.requestSync();
|
||||
expect(server.downloads).toHaveLength(count);
|
||||
expect((await rig.engine.areaCall('files.kept', undefined) as any).files.map((f: { name: string }) => f.name)).not.toContain('old');
|
||||
});
|
||||
|
||||
it('pushes out recent files for a pin, and keeps a pin past the cap', async () => {
|
||||
const server = new FakeFiles();
|
||||
const recent = server.addFile('recent', null, 20);
|
||||
const pin = server.addFile('pin', null, 20);
|
||||
const rig = await synced(server, { maxTotalBytes: 25 });
|
||||
await rig.engine.areaCall('files.noteOpened', ref(recent.id));
|
||||
await rig.engine.requestSync();
|
||||
rig.clock.at += 1000;
|
||||
await rig.engine.areaCall('files.pin', ref(pin.id));
|
||||
await rig.engine.requestSync();
|
||||
expect(await rig.engine.areaCall('files.read', ref(recent.id))).toBeNull();
|
||||
expect(await rig.engine.areaCall('files.read', ref(pin.id))).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps recently opened files under 500 MB whatever the total limit is', async () => {
|
||||
const server = new FakeFiles();
|
||||
const file = server.addFile('new', null, 10);
|
||||
const rig = await synced(server, { maxTotalBytes: 2 * FILE_CACHE_CAP_BYTES });
|
||||
// As if an older file already took all of it: only the row counts here.
|
||||
rig.db.run('INSERT INTO file_blob (jmap, blob_id, type, size, kept_at) VALUES (?, ?, ?, ?, ?)', [JMAP_ACCOUNT, 'huge', 'x', FILE_CACHE_CAP_BYTES, NOW - 1000]);
|
||||
rig.db.run("INSERT INTO file_node (jmap, id, name, kind, blob_id, size, json, opened_at) VALUES (?, 'hugenode', 'huge', 'file', 'huge', ?, '{}', ?)", [JMAP_ACCOUNT, FILE_CACHE_CAP_BYTES, NOW - 1000]);
|
||||
await rig.engine.areaCall('files.noteOpened', ref(file.id));
|
||||
await rig.engine.requestSync();
|
||||
expect(fileBlob({ db: rig.db }, JMAP_ACCOUNT, 'huge')).toBeNull();
|
||||
expect(await rig.engine.areaCall('files.read', ref(file.id))).not.toBeNull();
|
||||
expect((await rig.engine.areaCall('files.kept', undefined) as any).capBytes).toBe(FILE_CACHE_CAP_BYTES);
|
||||
});
|
||||
|
||||
it('drops an old opened file that does not fit rather than a newer one', async () => {
|
||||
const server = new FakeFiles();
|
||||
const a = server.addFile('a', null, 20);
|
||||
const b = server.addFile('b', null, 20);
|
||||
const rig = await synced(server, { maxTotalBytes: 25 });
|
||||
await rig.engine.areaCall('files.noteOpened', ref(a.id));
|
||||
rig.clock.at += 1000;
|
||||
await rig.engine.areaCall('files.noteOpened', ref(b.id));
|
||||
await rig.engine.requestSync();
|
||||
expect(await rig.engine.areaCall('files.read', ref(b.id))).not.toBeNull();
|
||||
expect(await rig.engine.areaCall('files.read', ref(a.id))).toBeNull();
|
||||
expect(server.downloads.map((d) => d.blobId)).toEqual([b.blobId, a.blobId]);
|
||||
const count = server.downloads.length;
|
||||
await rig.engine.requestSync();
|
||||
expect(server.downloads).toHaveLength(count);
|
||||
});
|
||||
});
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* FakeJmap with contacts, as measured on both Stalwarts (2026-11-03): uid is a
|
||||
* query filter, a second card with a uid is refused with invalidProperties,
|
||||
* unknown ids give per-record notFound, AddressBook state doesn't move when
|
||||
* cards change, `ContactCard/get` with ids null stops at 500.
|
||||
*/
|
||||
|
||||
import { TransportError, type JmapResponse, type MethodCall, type StoreSession } from '../../engine/transport';
|
||||
import { Collection, FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from './fake-jmap';
|
||||
|
||||
export const CONTACTS = 'urn:ietf:params:jmap:contacts';
|
||||
export const SHARED_ACCOUNT = 'shared1';
|
||||
export const MAIL_ONLY_ACCOUNT = 'mailonly';
|
||||
|
||||
export const CONTACTS_SESSION: StoreSession = {
|
||||
...FAKE_SESSION,
|
||||
primaryAccounts: { ...FAKE_SESSION.primaryAccounts, [CONTACTS]: JMAP_ACCOUNT },
|
||||
accounts: {
|
||||
[JMAP_ACCOUNT]: { name: 'a@example.org', isPersonal: true, accountCapabilities: { [CONTACTS]: {} } },
|
||||
[SHARED_ACCOUNT]: { name: 'team@example.org', isPersonal: false, accountCapabilities: { [CONTACTS]: {} } },
|
||||
[MAIL_ONLY_ACCOUNT]: { name: 'list@example.org', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } },
|
||||
},
|
||||
capabilities: { [CONTACTS]: {} },
|
||||
};
|
||||
|
||||
type Obj = Record<string, unknown> & { id: string };
|
||||
|
||||
/** The /changes rules of the base fake, for another collection. */
|
||||
export function changesFor(coll: Collection, args: Record<string, unknown>): Record<string, unknown> {
|
||||
const since = String(args.sinceState);
|
||||
if (!/^s\d+$/.test(since)) throw Object.assign(new Error('bad state'), { jmapError: 'invalidArguments' });
|
||||
const from = Number(since.slice(1));
|
||||
if (from < coll.truncatedBelow || from > coll.log.length) {
|
||||
throw Object.assign(new Error('log truncated'), { jmapError: 'cannotCalculateChanges' });
|
||||
}
|
||||
const maxChanges = Math.min(Number(args.maxChanges ?? 5000) || 5000, 5000);
|
||||
return { accountId: args.accountId, oldState: since, ...coll.changes(since, maxChanges) };
|
||||
}
|
||||
|
||||
function storedMedia(card: Obj): void {
|
||||
const media = card.media as Record<string, Record<string, unknown>> | undefined;
|
||||
for (const [key, entry] of Object.entries(media ?? {})) {
|
||||
if (typeof entry.uri !== 'string' || !entry.uri.startsWith('data:')) continue;
|
||||
const { uri, ...rest } = entry;
|
||||
(media as Record<string, unknown>)[key] = { ...rest, blobId: `stored-${uri.length}-${key}` };
|
||||
}
|
||||
}
|
||||
|
||||
function applyServerPatch(card: Obj, patch: Record<string, unknown>): void {
|
||||
for (const [path, value] of Object.entries(patch)) {
|
||||
const steps = path.split('/');
|
||||
let node = card as Record<string, unknown>;
|
||||
for (const step of steps.slice(0, -1)) node = node[step] as Record<string, unknown>;
|
||||
const last = steps[steps.length - 1];
|
||||
if (value === null) delete node[last];
|
||||
else node[last] = value;
|
||||
}
|
||||
}
|
||||
|
||||
class ContactAccount {
|
||||
readonly books = new Map<string, Obj>();
|
||||
readonly cards = new Map<string, Obj>();
|
||||
readonly coll = { AddressBook: new Collection(), ContactCard: new Collection() };
|
||||
}
|
||||
|
||||
export class FakeContacts extends FakeJmap {
|
||||
private readonly contactAccounts = new Map<string, ContactAccount>();
|
||||
private nextCard = 1;
|
||||
/** Set to apply the next ContactCard/set and then lose its response. */
|
||||
loseNextSet = false;
|
||||
/** Card ids (or creation ids) whose next set is refused with this error. */
|
||||
readonly refuse = new Map<string, { type: string; description?: string; properties?: string[] }>();
|
||||
/** A server that lets two cards have one uid, as nothing in the RFC forbids it. */
|
||||
allowDuplicateUids = false;
|
||||
/** Stalwart 1.0 stores a data: URI in `media` as a blob: `uri` goes, `blobId` comes (measured). */
|
||||
mediaAsBlobs = false;
|
||||
/** Set to apply the next ContactCard/set, answer it, and then lose the network. */
|
||||
offlineAfterSet = false;
|
||||
/** Set to lose the network before the next ContactCard/set reaches the server. */
|
||||
offlineBeforeSet = false;
|
||||
/** The most ids ContactCard/get and calls a request take, as a session can say. */
|
||||
maxObjectsInGet = 500;
|
||||
/** Answers a ContactCard/set call as a whole, instead of handling it. */
|
||||
failSet: ((args: Record<string, unknown>) => { jmapError: string } | { http: number } | null) | null = null;
|
||||
|
||||
constructor(books: string[] = ['book1']) {
|
||||
super();
|
||||
for (const id of books) this.addBook({ id, name: id, isDefault: id === books[0] });
|
||||
}
|
||||
|
||||
account(id: string = JMAP_ACCOUNT): ContactAccount {
|
||||
let found = this.contactAccounts.get(id);
|
||||
if (!found) {
|
||||
found = new ContactAccount();
|
||||
this.contactAccounts.set(id, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
addBook(book: Obj, accountId: string = JMAP_ACCOUNT): void {
|
||||
const account = this.account(accountId);
|
||||
account.books.set(book.id, book);
|
||||
account.coll.AddressBook.push('created', book.id);
|
||||
}
|
||||
|
||||
addCard(partial: Partial<Obj> = {}, accountId: string = JMAP_ACCOUNT): Obj {
|
||||
const account = this.account(accountId);
|
||||
const n = this.nextCard++;
|
||||
const card: Obj = {
|
||||
'@type': 'Card',
|
||||
version: '1.0',
|
||||
id: `c${n}`,
|
||||
uid: `urn:uuid:card-${n}`,
|
||||
addressBookIds: { [[...account.books.keys()][0] ?? 'book1']: true },
|
||||
kind: 'individual',
|
||||
name: { full: `Person ${n}` },
|
||||
...partial,
|
||||
};
|
||||
account.cards.set(card.id, card);
|
||||
account.coll.ContactCard.push('created', card.id);
|
||||
return card;
|
||||
}
|
||||
|
||||
editCard(id: string, patch: Record<string, unknown>, accountId: string = JMAP_ACCOUNT): void {
|
||||
const account = this.account(accountId);
|
||||
applyServerPatch(account.cards.get(id) as Obj, patch);
|
||||
account.coll.ContactCard.push('updated', id);
|
||||
}
|
||||
|
||||
removeCard(id: string, accountId: string = JMAP_ACCOUNT): void {
|
||||
const account = this.account(accountId);
|
||||
account.cards.delete(id);
|
||||
account.coll.ContactCard.push('destroyed', id);
|
||||
}
|
||||
|
||||
private contactSet(account: ContactAccount, args: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = { accountId: args.accountId, oldState: account.coll.ContactCard.state() };
|
||||
const created: Record<string, unknown> = {};
|
||||
const notCreated: Record<string, unknown> = {};
|
||||
const updated: Record<string, unknown> = {};
|
||||
const notUpdated: Record<string, unknown> = {};
|
||||
const destroyed: string[] = [];
|
||||
const notDestroyed: Record<string, unknown> = {};
|
||||
for (const [cid, raw] of Object.entries((args.create ?? {}) as Record<string, Record<string, unknown>>)) {
|
||||
const refused = this.refuse.get(cid) ?? this.refuse.get(String(raw.uid));
|
||||
const clash = [...account.cards.values()].find((card) => card.uid === raw.uid);
|
||||
if (refused) {
|
||||
notCreated[cid] = refused;
|
||||
} else if (clash && !this.allowDuplicateUids) {
|
||||
notCreated[cid] = { type: 'invalidProperties', description: `Contact with UID ${String(raw.uid)} already exists with id ${clash.id}.`, properties: ['uid'] };
|
||||
} else {
|
||||
const id = `c${this.nextCard++}`;
|
||||
const stored: Obj = { '@type': 'Card', version: '1.0', ...structuredClone(raw), id };
|
||||
if (this.mediaAsBlobs) storedMedia(stored);
|
||||
account.cards.set(id, stored);
|
||||
account.coll.ContactCard.push('created', id);
|
||||
created[cid] = { id };
|
||||
}
|
||||
}
|
||||
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, Record<string, unknown>>)) {
|
||||
const card = account.cards.get(id);
|
||||
const refused = this.refuse.get(id);
|
||||
if (refused) notUpdated[id] = refused;
|
||||
else if (!card) notUpdated[id] = { type: 'notFound' };
|
||||
else {
|
||||
applyServerPatch(card, structuredClone(patch));
|
||||
if (this.mediaAsBlobs) storedMedia(card);
|
||||
account.coll.ContactCard.push('updated', id);
|
||||
updated[id] = null;
|
||||
}
|
||||
}
|
||||
for (const id of (args.destroy ?? []) as string[]) {
|
||||
const refused = this.refuse.get(id);
|
||||
if (refused) notDestroyed[id] = refused;
|
||||
else if (!account.cards.has(id)) notDestroyed[id] = { type: 'notFound' };
|
||||
else {
|
||||
account.cards.delete(id);
|
||||
account.coll.ContactCard.push('destroyed', id);
|
||||
destroyed.push(id);
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries({ created, notCreated, updated, notUpdated, notDestroyed })) {
|
||||
if (Object.keys(value).length > 0) result[key] = value;
|
||||
}
|
||||
if (destroyed.length > 0) result.destroyed = destroyed;
|
||||
result.newState = account.coll.ContactCard.state();
|
||||
return result;
|
||||
}
|
||||
|
||||
protected override handle(name: string, args: Record<string, unknown>): Record<string, unknown> {
|
||||
const account = this.account(String(args.accountId));
|
||||
switch (name) {
|
||||
case 'AddressBook/get': {
|
||||
const ids = args.ids as string[] | null | undefined;
|
||||
const all = [...account.books.values()];
|
||||
return {
|
||||
accountId: args.accountId,
|
||||
state: account.coll.AddressBook.state(),
|
||||
list: ids ? all.filter((b) => ids.includes(b.id)) : all,
|
||||
notFound: ids ? ids.filter((id) => !account.books.has(id)) : [],
|
||||
};
|
||||
}
|
||||
case 'AddressBook/changes':
|
||||
return changesFor(account.coll.AddressBook, args);
|
||||
case 'ContactCard/changes':
|
||||
return changesFor(account.coll.ContactCard, args);
|
||||
case 'ContactCard/query': {
|
||||
const filter = (args.filter ?? {}) as { uid?: string; inAddressBook?: string };
|
||||
let list = [...account.cards.values()];
|
||||
if (filter.uid) list = list.filter((c) => c.uid === filter.uid);
|
||||
if (filter.inAddressBook) list = list.filter((c) => (c.addressBookIds as Record<string, boolean>)[filter.inAddressBook as string]);
|
||||
let position = Number(args.position ?? 0);
|
||||
if (args.anchor !== undefined) {
|
||||
const at = list.findIndex((c) => c.id === args.anchor);
|
||||
if (at < 0) throw Object.assign(new Error('anchorNotFound'), { jmapError: 'anchorNotFound' });
|
||||
position = at + Number(args.anchorOffset ?? 0);
|
||||
}
|
||||
const limit = Number(args.limit ?? 5000) || 5000;
|
||||
return {
|
||||
accountId: args.accountId,
|
||||
queryState: account.coll.ContactCard.state(),
|
||||
canCalculateChanges: true,
|
||||
position,
|
||||
ids: list.slice(position, position + Math.min(limit, 5000)).map((c) => c.id),
|
||||
...(args.calculateTotal ? { total: list.length } : {}),
|
||||
};
|
||||
}
|
||||
case 'ContactCard/get': {
|
||||
const ids = args.ids as string[] | null | undefined;
|
||||
if (ids && ids.length > this.maxObjectsInGet) throw Object.assign(new Error('requestTooLarge'), { jmapError: 'requestTooLarge' });
|
||||
const chosen = ids ?? [...account.cards.keys()].slice(0, this.maxObjectsInGet);
|
||||
return {
|
||||
accountId: args.accountId,
|
||||
state: account.coll.ContactCard.state(),
|
||||
list: chosen.filter((id) => account.cards.has(id)).map((id) => structuredClone(account.cards.get(id))),
|
||||
notFound: chosen.filter((id) => !account.cards.has(id)),
|
||||
};
|
||||
}
|
||||
case 'ContactCard/set': {
|
||||
const failure = this.failSet?.(args);
|
||||
if (failure && 'http' in failure) throw new TransportError('http', `JMAP request failed: ${failure.http}`, failure.http, 'urn:ietf:params:jmap:error:limit');
|
||||
if (failure) throw Object.assign(new Error(failure.jmapError), failure);
|
||||
return this.contactSet(account, args);
|
||||
}
|
||||
default:
|
||||
return super.handle(name, args);
|
||||
}
|
||||
}
|
||||
|
||||
override async request(calls: MethodCall[], using?: string[]): Promise<JmapResponse> {
|
||||
// The real server fails the whole request for a capability it doesn't list.
|
||||
void using;
|
||||
if (this.offlineBeforeSet && calls.some(([name]) => name === 'ContactCard/set')) {
|
||||
this.offlineBeforeSet = false;
|
||||
throw new TransportError('network', 'Failed to fetch');
|
||||
}
|
||||
const response = await super.request(calls);
|
||||
if (this.offlineAfterSet && calls.some(([name]) => name === 'ContactCard/set')) {
|
||||
this.offlineAfterSet = false;
|
||||
this.offline = true;
|
||||
}
|
||||
if (this.loseNextSet && calls.some(([name]) => name === 'ContactCard/set')) {
|
||||
this.loseNextSet = false;
|
||||
throw new TransportError('network', 'Connection dropped after the server took the request');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* FakeJmap with files, as measured on both Stalwarts (2026-11-03) and as the
|
||||
* client's comments record: `filter: {parentId: null}` is an HTTP 400 that
|
||||
* fails the whole request, a folder has null blobId, type and size. What differs
|
||||
* between versions is set per server:
|
||||
* - `style` is how a node says what it is: `nodeType` (0.16.6 and later, 1.0 as
|
||||
* tested), `blob` (0.16.0-5: nothing but blobId null), `draft` (1.0.0: `@type`
|
||||
* and `mediaType`).
|
||||
* - `getAll`: ids null past 500 is silently cut on 0.16 and refused on 1.0.
|
||||
* - `queryOmitsFolders`: FileNode/query before 0.16.6.
|
||||
*/
|
||||
|
||||
import { TransportError, type StoreSession } from '../../engine/transport';
|
||||
import { changesFor } from './fake-contacts';
|
||||
import { Collection, FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from './fake-jmap';
|
||||
|
||||
export const FILES = 'urn:ietf:params:jmap:filenode';
|
||||
export const SHARED_FILES_ACCOUNT = 'shared1';
|
||||
|
||||
export const FILES_SESSION: StoreSession = {
|
||||
...FAKE_SESSION,
|
||||
primaryAccounts: { ...FAKE_SESSION.primaryAccounts, [FILES]: JMAP_ACCOUNT },
|
||||
accounts: {
|
||||
[JMAP_ACCOUNT]: { name: 'a@example.org', isPersonal: true, accountCapabilities: { [FILES]: {} } },
|
||||
[SHARED_FILES_ACCOUNT]: { name: 'team@example.org', isPersonal: false, accountCapabilities: { [FILES]: {} } },
|
||||
},
|
||||
capabilities: { [FILES]: {} },
|
||||
};
|
||||
|
||||
export interface FakeNode {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
blobId: string | null;
|
||||
type: string | null;
|
||||
size: number | null;
|
||||
modified?: string;
|
||||
}
|
||||
|
||||
export type FolderStyle = 'nodeType' | 'blob' | 'draft';
|
||||
|
||||
export interface FakeFilesOptions {
|
||||
style?: FolderStyle;
|
||||
getAll?: 'cut' | 'refuse';
|
||||
queryOmitsFolders?: boolean;
|
||||
}
|
||||
|
||||
class FilesAccount {
|
||||
readonly nodes = new Map<string, FakeNode>();
|
||||
readonly coll = new Collection();
|
||||
}
|
||||
|
||||
export class FakeFiles extends FakeJmap {
|
||||
private readonly fileAccounts = new Map<string, FilesAccount>();
|
||||
private readonly options: Required<FakeFilesOptions>;
|
||||
private nextNode = 1;
|
||||
readonly blobs = new Map<string, Uint8Array>();
|
||||
/** In order, with the account. */
|
||||
readonly downloads: Array<{ jmap: string; blobId: string }> = [];
|
||||
downloading = 0;
|
||||
mostAtOnce = 0;
|
||||
|
||||
constructor(options: FakeFilesOptions = {}) {
|
||||
super();
|
||||
this.options = { style: 'nodeType', getAll: 'cut', queryOmitsFolders: false, ...options };
|
||||
}
|
||||
|
||||
account(id: string = JMAP_ACCOUNT): FilesAccount {
|
||||
let found = this.fileAccounts.get(id);
|
||||
if (!found) {
|
||||
found = new FilesAccount();
|
||||
this.fileAccounts.set(id, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
addFolder(name: string, parentId: string | null = null, id?: string, accountId: string = JMAP_ACCOUNT): FakeNode {
|
||||
const account = this.account(accountId);
|
||||
const node: FakeNode = { id: id ?? `n${this.nextNode++}`, parentId, name, blobId: null, type: null, size: null };
|
||||
account.nodes.set(node.id, node);
|
||||
account.coll.push('created', node.id);
|
||||
return node;
|
||||
}
|
||||
|
||||
addFile(name: string, parentId: string | null = null, content: Uint8Array | number = 10, extra: Partial<FakeNode> = {}, accountId: string = JMAP_ACCOUNT): FakeNode {
|
||||
const account = this.account(accountId);
|
||||
const id = extra.id ?? `n${this.nextNode++}`;
|
||||
const blobId = extra.blobId ?? `blob-${id}`;
|
||||
const data = typeof content === 'number' ? new Uint8Array(content).fill(id.length) : content;
|
||||
this.blobs.set(blobId, data);
|
||||
const node: FakeNode = { id, parentId, name, blobId, type: 'text/plain', size: data.length, ...extra };
|
||||
account.nodes.set(id, node);
|
||||
account.coll.push('created', id);
|
||||
return node;
|
||||
}
|
||||
|
||||
/** A new version of the file: new blob, same node. */
|
||||
replaceContent(id: string, content: Uint8Array, accountId: string = JMAP_ACCOUNT): string {
|
||||
const account = this.account(accountId);
|
||||
const node = account.nodes.get(id) as FakeNode;
|
||||
const blobId = `${node.blobId as string}-v`;
|
||||
this.blobs.set(blobId, content);
|
||||
node.blobId = blobId;
|
||||
node.size = content.length;
|
||||
account.coll.push('updated', id);
|
||||
return blobId;
|
||||
}
|
||||
|
||||
renameNode(id: string, name: string, accountId: string = JMAP_ACCOUNT): void {
|
||||
const account = this.account(accountId);
|
||||
(account.nodes.get(id) as FakeNode).name = name;
|
||||
account.coll.push('updated', id);
|
||||
}
|
||||
|
||||
removeNode(id: string, accountId: string = JMAP_ACCOUNT): void {
|
||||
const account = this.account(accountId);
|
||||
account.nodes.delete(id);
|
||||
account.coll.push('destroyed', id);
|
||||
}
|
||||
|
||||
private wire(node: FakeNode, properties: string[] | undefined): Record<string, unknown> {
|
||||
const isFolder = node.blobId === null;
|
||||
const full: Record<string, unknown> = { id: node.id, parentId: node.parentId, name: node.name, blobId: node.blobId, size: node.size, created: '2026-10-01T00:00:00Z', modified: node.modified ?? '2026-10-01T00:00:00Z' };
|
||||
if (this.options.style === 'draft') {
|
||||
full['@type'] = isFolder ? 'Directory' : 'File';
|
||||
full.mediaType = node.type;
|
||||
} else {
|
||||
full.type = node.type;
|
||||
if (this.options.style === 'nodeType') full.nodeType = isFolder ? 'directory' : 'file';
|
||||
}
|
||||
if (!properties) return full;
|
||||
const out: Record<string, unknown> = { id: node.id };
|
||||
for (const p of properties) if (p in full) out[p] = full[p];
|
||||
return out;
|
||||
}
|
||||
|
||||
protected override handle(name: string, args: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!name.startsWith('FileNode/')) return super.handle(name, args);
|
||||
const account = this.account(String(args.accountId));
|
||||
switch (name) {
|
||||
case 'FileNode/changes':
|
||||
return changesFor(account.coll, args);
|
||||
case 'FileNode/get': {
|
||||
const ids = args.ids as string[] | null;
|
||||
if (ids && ids.length > 500) throw Object.assign(new Error('requestTooLarge'), { jmapError: 'requestTooLarge' });
|
||||
if (!ids && account.nodes.size > 500 && this.options.getAll === 'refuse') {
|
||||
throw Object.assign(new Error('requestTooLarge'), { jmapError: 'requestTooLarge' });
|
||||
}
|
||||
const chosen = ids ?? [...account.nodes.keys()].slice(0, 500);
|
||||
const properties = args.properties as string[] | undefined;
|
||||
return {
|
||||
accountId: args.accountId,
|
||||
state: account.coll.state(),
|
||||
list: chosen.filter((id) => account.nodes.has(id)).map((id) => this.wire(account.nodes.get(id) as FakeNode, properties)),
|
||||
notFound: chosen.filter((id) => !account.nodes.has(id)),
|
||||
};
|
||||
}
|
||||
case 'FileNode/query': {
|
||||
const filter = (args.filter ?? {}) as { parentId?: string | null };
|
||||
// Measured on both servers: this fails the whole request.
|
||||
if (filter.parentId === null) throw new TransportError('http', 'JMAP request failed: 400', 400, 'urn:ietf:params:jmap:error:notRequest');
|
||||
let list = [...account.nodes.values()];
|
||||
if (this.options.queryOmitsFolders) list = list.filter((n) => n.blobId !== null);
|
||||
if (filter.parentId) list = list.filter((n) => n.parentId === filter.parentId);
|
||||
const position = Number(args.position ?? 0);
|
||||
const limit = Math.min(Number(args.limit ?? 5000) || 5000, 5000);
|
||||
return {
|
||||
accountId: args.accountId,
|
||||
queryState: account.coll.state(),
|
||||
canCalculateChanges: true,
|
||||
position,
|
||||
ids: list.slice(position, position + limit).map((n) => n.id),
|
||||
...(args.calculateTotal ? { total: list.length } : {}),
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw Object.assign(new Error(name), { jmapError: 'unknownMethod' });
|
||||
}
|
||||
}
|
||||
|
||||
override async download(jmap: string, blobId: string): Promise<Uint8Array> {
|
||||
if (this.offline) throw new TransportError('network', 'Failed to fetch');
|
||||
this.downloads.push({ jmap, blobId });
|
||||
this.downloading += 1;
|
||||
this.mostAtOnce = Math.max(this.mostAtOnce, this.downloading);
|
||||
await Promise.resolve();
|
||||
this.downloading -= 1;
|
||||
const data = this.blobs.get(blobId);
|
||||
if (!data) throw new TransportError('http', 'Download failed: 404', 404);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -286,7 +286,7 @@ export class FakeJmap implements JmapTransport {
|
||||
return { methodResponses: done };
|
||||
}
|
||||
|
||||
async download(): Promise<Uint8Array> {
|
||||
async download(_jmap: string, _blobId: string, _type: string, _name: string): Promise<Uint8Array> {
|
||||
return new Uint8Array();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* test hands it a transport.
|
||||
*/
|
||||
|
||||
import { DEFAULT_SCOPE } from '../../area';
|
||||
import { DEFAULT_SCOPE, type StoreScope } from '../../area';
|
||||
import { STORE_AREAS } from '../../areas';
|
||||
import type { StoreBackend } from '../../client/api';
|
||||
import { AccountEngine } from '../../engine/account-engine';
|
||||
@@ -88,6 +88,11 @@ export class InProcessStore implements StoreBackend {
|
||||
return (result === undefined ? undefined : acrossWorker(result)) as T;
|
||||
}
|
||||
|
||||
async setScope(account: string, scope: StoreScope): Promise<void> {
|
||||
const engine = this.engine(account);
|
||||
if (engine.setScope(scope)) await engine.requestSync();
|
||||
}
|
||||
|
||||
async sync(account: string): Promise<void> {
|
||||
await this.engine(account).requestSync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_SCOPE } from '../area';
|
||||
import { mailArea } from '../mail';
|
||||
import type { StorageAdapter } from '../storage';
|
||||
import { StoreHost, type HostStorage } from '../worker/host';
|
||||
import { FAKE_SESSION } from './helpers/fake-jmap';
|
||||
import { openMemoryStore } from './helpers/node-adapter';
|
||||
|
||||
const KEY = 'a'.repeat(64);
|
||||
|
||||
class FakeStorage implements HostStorage {
|
||||
readonly dbs = new Map<string, StorageAdapter>();
|
||||
removed: string[] = [];
|
||||
files(): string[] {
|
||||
return [...this.dbs.keys()];
|
||||
}
|
||||
async open(file: string): Promise<StorageAdapter> {
|
||||
let db = this.dbs.get(file);
|
||||
if (!db) {
|
||||
db = openMemoryStore();
|
||||
this.dbs.set(file, db);
|
||||
}
|
||||
// The host closes a database; the "file" stays for the next open.
|
||||
return { ...db, exec: db.exec.bind(db), run: db.run.bind(db), all: db.all.bind(db), get: db.get.bind(db), transaction: db.transaction.bind(db), close() {} };
|
||||
}
|
||||
remove(file: string): boolean {
|
||||
this.removed.push(file);
|
||||
this.dbs.delete(`${file}-journal`);
|
||||
return this.dbs.delete(file);
|
||||
}
|
||||
}
|
||||
|
||||
function host(storage: FakeStorage): StoreHost {
|
||||
return new StoreHost({ areas: [mailArea], storage, emit: () => {}, needAuth: () => {} });
|
||||
}
|
||||
|
||||
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 it was created, also after a first try with no session', async () => {
|
||||
const storage = new FakeStorage();
|
||||
await expect(host(storage).openAccount(params('/a1.db', null))).rejects.toThrow('No session');
|
||||
expect(storage.files()).toEqual([]);
|
||||
const running = host(storage);
|
||||
expect(await running.openAccount(params('/a1.db'))).toEqual({ created: true });
|
||||
await running.closeAll();
|
||||
expect(await host(storage).openAccount(params('/a1.db', null))).toEqual({ created: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('removing an account', () => {
|
||||
it('tells every tab, once the file is gone', async () => {
|
||||
const storage = new FakeStorage();
|
||||
const events: unknown[] = [];
|
||||
const running = new StoreHost({ areas: [mailArea], storage, emit: (event) => events.push({ event, file: storage.files().length }), needAuth: () => {} });
|
||||
await running.openAccount(params('/a1.db'));
|
||||
events.length = 0;
|
||||
await running.removeAccount('a', '/a1.db');
|
||||
expect(events).toContainEqual({ event: { type: 'removed', account: 'a' }, file: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a scope from another tab', () => {
|
||||
it('reaches the engine over the channel call', async () => {
|
||||
const storage = new FakeStorage();
|
||||
const running = host(storage);
|
||||
await running.openAccount(params('/a1.db'));
|
||||
await running.call('setScope', { account: 'a', scope: { ...DEFAULT_SCOPE, days: 7 } });
|
||||
const stored = storage.dbs.get('/a1.db')!.get<{ v: string }>("SELECT v FROM meta WHERE k = 'scope'");
|
||||
expect(JSON.parse(String(stored?.v)).days).toBe(7);
|
||||
await expect(running.call('setScope', { account: 'nobody', scope: DEFAULT_SCOPE })).rejects.toMatchObject({ name: 'StoreAccountClosedError' });
|
||||
});
|
||||
});
|
||||
@@ -444,7 +444,7 @@ describe('query plans', () => {
|
||||
},
|
||||
};
|
||||
const answer = await answerRequest(
|
||||
{ account: 'a', db: spied as typeof db, session: FAKE_SESSION, scope: EVERYTHING, now: Date.now, emit: () => {}, overlay: (_jmap, _type, records) => records, defaultJmap: JMAP_ACCOUNT, invertedKeywordSort: false },
|
||||
{ account: 'a', db: spied as typeof db, session: FAKE_SESSION, scope: EVERYTHING, now: Date.now, emit: () => {}, overlay: (_jmap, _type, records) => records, keptByOthers: () => 0, sparedRecords: () => [], requestSync: async () => {}, defaultJmap: JMAP_ACCOUNT, invertedKeywordSort: false },
|
||||
buildMethodTable([mailArea]),
|
||||
[['Email/query', { filter: INBOX, sort: [{ property: 'receivedAt', isAscending: false }], limit: 5 }, 'q']],
|
||||
);
|
||||
|
||||
@@ -431,6 +431,57 @@ describe('the page\'s store', () => {
|
||||
store.stop();
|
||||
});
|
||||
|
||||
it('opens an account again once another tab removed it', async () => {
|
||||
allowEngine();
|
||||
const store = getStore();
|
||||
await store.ensureOpen('a@x');
|
||||
const worker = FakeWorker.all[0];
|
||||
worker.becomeOwner();
|
||||
await vi.waitFor(() => expect(worker.posted.filter((m) => m.kind === 'open')).toHaveLength(1));
|
||||
await store.ensureOpen('a@x');
|
||||
expect(worker.posted.filter((m) => m.kind === 'open')).toHaveLength(1);
|
||||
|
||||
worker.onmessage?.({ data: { kind: 'event', event: { type: 'removed', account: 'a@x' } } } as MessageEvent);
|
||||
await store.ensureOpen('a@x');
|
||||
await vi.waitFor(() => expect(worker.posted.filter((m) => m.kind === 'open')).toHaveLength(2));
|
||||
store.stop();
|
||||
});
|
||||
|
||||
it('sends the scope at every refresh once it has heard the policy, and none before', async () => {
|
||||
let hear: () => void = () => {};
|
||||
configureStore({
|
||||
inputs: () => ({ session: FAKE_SESSION, auth: 'Basic x', scope: { days: 7, attachments: true, maxAttachmentBytes: 1, maxTotalBytes: 1 }, invertedKeywordSort: true }),
|
||||
refreshAuth: async () => null,
|
||||
onEvent: () => {},
|
||||
ownerTaken: () => new Promise<void>((resolve) => { hear = resolve; }),
|
||||
});
|
||||
const store = getStore();
|
||||
await store.ensureOpen('a@x');
|
||||
const worker = FakeWorker.all[0];
|
||||
const owner = new FakeChannel('bulwark-store-rpc');
|
||||
const sent: string[] = [];
|
||||
owner.onmessage = (event) => {
|
||||
const message = event.data as { kind: string; id: string; from: string; method: string };
|
||||
if (message.kind === 'who-owns') owner.postMessage({ kind: 'owner-changed', owner: 'worker' });
|
||||
if (message.kind !== 'rpc') return;
|
||||
sent.push(message.method);
|
||||
owner.postMessage({ kind: 'reply', id: message.id, to: message.from });
|
||||
};
|
||||
worker.becomeOwner();
|
||||
await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'open')).toBe(true));
|
||||
const open = worker.posted.find((m) => m.kind === 'open') as { params: { scope?: unknown } };
|
||||
expect(open.params.scope).toBeUndefined();
|
||||
store.refresh('a@x');
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
hear();
|
||||
await vi.waitFor(() => expect(sent).toEqual(['setScope']));
|
||||
store.refresh('a@x');
|
||||
await vi.waitFor(() => expect(sent).toEqual(['setScope', 'setScope']));
|
||||
store.stop();
|
||||
});
|
||||
|
||||
it('stops waiting when the engine couldn\'t start in this browser', async () => {
|
||||
allowEngine();
|
||||
const store = getStore();
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_SCOPE, type StoreArea, type StoreScope } from '../area';
|
||||
import { calendarArea } from '../calendar';
|
||||
import { AccountEngine } from '../engine/account-engine';
|
||||
import { mailArea } from '../mail';
|
||||
import { outboxArea } from '../outbox';
|
||||
import { CALENDAR_SESSION, FakeCalendarJmap } from './helpers/fake-calendar';
|
||||
import { FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from './helpers/fake-jmap';
|
||||
import { openMemoryStore } from './helpers/node-adapter';
|
||||
|
||||
const NOW = Date.UTC(2026, 10, 3);
|
||||
const DAY = 86_400_000;
|
||||
const EVERYTHING: StoreScope = { ...DEFAULT_SCOPE, days: null };
|
||||
const iso = (daysAgo: number) => new Date(NOW - daysAgo * DAY).toISOString();
|
||||
|
||||
class Files extends FakeJmap {
|
||||
downloads: string[] = [];
|
||||
sizes = new Map<string, number>();
|
||||
override async download(_jmap?: string, blobId?: string): Promise<Uint8Array> {
|
||||
this.downloads.push(String(blobId));
|
||||
return new Uint8Array(this.sizes.get(String(blobId)) ?? 3);
|
||||
}
|
||||
withFile(id: string, blobId: string, size: number, daysAgo: number) {
|
||||
this.sizes.set(blobId, size);
|
||||
return this.addEmail({
|
||||
id,
|
||||
receivedAt: iso(daysAgo),
|
||||
hasAttachment: true,
|
||||
attachments: [{ partId: '2', blobId, name: `${blobId}.pdf`, type: 'application/pdf', size }],
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
|
||||
function setup(server: FakeJmap, scope: StoreScope, areas: StoreArea[] = [mailArea], clock = { now: NOW }, db = openMemoryStore()) {
|
||||
const engine = new AccountEngine({
|
||||
account: 'a@fake.test', db, session: FAKE_SESSION, transport: server, areas, scope, now: () => clock.now, sleep: async () => {},
|
||||
});
|
||||
return { engine, db, clock };
|
||||
}
|
||||
|
||||
const ids = (db: ReturnType<typeof openMemoryStore>, table = 'email') => db.all<{ id: string }>(`SELECT id FROM ${table} ORDER BY id`).map((row) => row.id);
|
||||
const blobs = (db: ReturnType<typeof openMemoryStore>) => db.all<{ blob_id: string }>("SELECT blob_id FROM blob WHERE blob_id LIKE 'b-%' ORDER BY blob_id").map((row) => row.blob_id);
|
||||
const count = (db: ReturnType<typeof openMemoryStore>, table: string) => Number(db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM ${table}`)?.n);
|
||||
|
||||
describe('a tightened scope', () => {
|
||||
it('prunes mail outside the window without fetching what stays', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail({ id: 'old', receivedAt: iso(100) });
|
||||
server.addEmail({ id: 'mid', receivedAt: iso(40) });
|
||||
server.addEmail({ id: 'new', receivedAt: iso(5) });
|
||||
const { engine, db } = setup(server, EVERYTHING);
|
||||
await engine.requestSync();
|
||||
expect(ids(db)).toEqual(['mid', 'new', 'old']);
|
||||
|
||||
server.calls.length = 0;
|
||||
expect(engine.setScope({ ...EVERYTHING, days: 30 })).toBe(true);
|
||||
await engine.requestSync();
|
||||
|
||||
expect(ids(db)).toEqual(['new']);
|
||||
expect(count(db, 'email_body')).toBe(1);
|
||||
expect(count(db, 'email_mailbox')).toBe(1);
|
||||
// No resync: neither a listing nor the bodies of what stayed.
|
||||
expect(server.calls.filter(([name]) => name === 'Email/query')).toEqual([]);
|
||||
expect(server.calls.filter(([name, args]) => name === 'Email/get' && (args as { fetchAllBodyValues?: boolean }).fetchAllBodyValues)).toEqual([]);
|
||||
});
|
||||
|
||||
it('prunes without a network', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail({ id: 'old', receivedAt: iso(100) });
|
||||
server.addEmail({ id: 'new', receivedAt: iso(5) });
|
||||
const { engine, db } = setup(server, EVERYTHING);
|
||||
await engine.requestSync();
|
||||
engine.setOnline(false);
|
||||
server.calls.length = 0;
|
||||
engine.setScope({ ...EVERYTHING, days: 30 });
|
||||
await engine.requestSync();
|
||||
expect(ids(db)).toEqual(['new']);
|
||||
expect(server.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('applies when the store opens under a tighter scope than it was left with', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail({ id: 'old', receivedAt: iso(100) });
|
||||
server.addEmail({ id: 'new', receivedAt: iso(5) });
|
||||
const first = setup(server, EVERYTHING);
|
||||
await first.engine.requestSync();
|
||||
await first.engine.close();
|
||||
const second = setup(server, { ...EVERYTHING, days: 30 }, [mailArea], { now: NOW }, first.db);
|
||||
await second.engine.requestSync();
|
||||
expect(ids(first.db)).toEqual(['new']);
|
||||
});
|
||||
|
||||
it('keeps the last scope when the page can\'t tell yet', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail({ id: 'old', receivedAt: iso(100) });
|
||||
server.addEmail({ id: 'new', receivedAt: iso(5) });
|
||||
const first = setup(server, { ...EVERYTHING, days: 30 });
|
||||
await first.engine.requestSync();
|
||||
await first.engine.close();
|
||||
server.calls.length = 0;
|
||||
const second = new AccountEngine({
|
||||
account: 'a@fake.test', db: first.db, session: FAKE_SESSION, transport: server, areas: [mailArea], now: () => NOW, sleep: async () => {},
|
||||
});
|
||||
await second.requestSync();
|
||||
// Not the default's 90 days: that would look like a wider window to fetch.
|
||||
expect(server.calls.filter(([name]) => name === 'Email/query')).toEqual([]);
|
||||
expect(ids(first.db)).toEqual(['new']);
|
||||
});
|
||||
|
||||
it('moves with the clock, once a day', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail({ id: 'ageing', receivedAt: iso(29) });
|
||||
const { engine, db, clock } = setup(server, { ...EVERYTHING, days: 30 });
|
||||
await engine.requestSync();
|
||||
expect(ids(db)).toEqual(['ageing']);
|
||||
clock.now = NOW + 2 * DAY;
|
||||
await engine.requestSync();
|
||||
expect(ids(db)).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops attachments when they are forbidden, and can fetch them again', async () => {
|
||||
const server = new Files();
|
||||
server.withFile('m', 'b-1', 10, 1);
|
||||
const { engine, db } = setup(server, EVERYTHING);
|
||||
await engine.requestSync();
|
||||
expect(blobs(db)).toEqual(['b-1']);
|
||||
|
||||
engine.setScope({ ...EVERYTHING, attachments: false });
|
||||
await engine.requestSync();
|
||||
expect(blobs(db)).toEqual([]);
|
||||
expect(ids(db)).toEqual(['m']);
|
||||
|
||||
engine.setScope(EVERYTHING);
|
||||
await engine.requestSync();
|
||||
expect(blobs(db)).toEqual(['b-1']);
|
||||
});
|
||||
|
||||
it('takes the chunks of a forbidden attachment too, not only its row', async () => {
|
||||
const server = new Files();
|
||||
server.withFile('m', 'b-1', 3 * 1024 * 1024, 1);
|
||||
const { engine, db } = setup(server, EVERYTHING);
|
||||
await engine.requestSync();
|
||||
expect(blobs(db)).toEqual(['b-1']);
|
||||
expect(count(db, 'blob_chunk')).toBeGreaterThan(0);
|
||||
engine.setScope({ ...EVERYTHING, attachments: false });
|
||||
await engine.requestSync();
|
||||
expect(count(db, 'blob')).toBe(0);
|
||||
expect(count(db, 'blob_chunk')).toBe(0);
|
||||
});
|
||||
|
||||
it('never takes the file of a pinned message for the total', async () => {
|
||||
const server = new Files();
|
||||
server.withFile('pinned', 'b-pinned', 100, 3);
|
||||
const pinned = server.emails.get('pinned') as { keywords?: Record<string, boolean> };
|
||||
pinned.keywords = { ...pinned.keywords, $pinned: true };
|
||||
server.withFile('other', 'b-other', 100, 2);
|
||||
const { engine, db } = setup(server, EVERYTHING);
|
||||
await engine.requestSync();
|
||||
engine.setScope({ ...EVERYTHING, maxTotalBytes: 150 });
|
||||
await engine.requestSync();
|
||||
expect(blobs(db)).toEqual(['b-pinned']);
|
||||
});
|
||||
|
||||
it('keeps an old draft that is only on this device', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail({ id: 'old', receivedAt: iso(100) });
|
||||
server.addEmail({ id: 'local-draft', receivedAt: iso(100) });
|
||||
const { engine, db } = setup(server, EVERYTHING, [mailArea, outboxArea]);
|
||||
await engine.requestSync();
|
||||
db.run("INSERT INTO draft_pending (local_id, jmap, seq, create_json, message_id, created_at) VALUES ('local-draft', ?, 1, '{}', 'm@x', 1)", [JMAP_ACCOUNT]);
|
||||
engine.setScope({ ...EVERYTHING, days: 30 });
|
||||
await engine.requestSync();
|
||||
expect(ids(db)).toEqual(['local-draft']);
|
||||
});
|
||||
|
||||
it('drops attachments over the new size', async () => {
|
||||
const server = new Files();
|
||||
server.withFile('small', 'b-small', 10, 1);
|
||||
server.withFile('big', 'b-big', 1000, 2);
|
||||
const { engine, db } = setup(server, EVERYTHING);
|
||||
await engine.requestSync();
|
||||
expect(blobs(db)).toEqual(['b-big', 'b-small']);
|
||||
engine.setScope({ ...EVERYTHING, maxAttachmentBytes: 100 });
|
||||
await engine.requestSync();
|
||||
expect(blobs(db)).toEqual(['b-small']);
|
||||
});
|
||||
|
||||
it('drops the oldest attachments over the new total, and doesn\'t fetch them back', async () => {
|
||||
const server = new Files();
|
||||
server.withFile('newest', 'b-newest', 100, 1);
|
||||
server.withFile('middle', 'b-middle', 100, 2);
|
||||
server.withFile('oldest', 'b-oldest', 100, 3);
|
||||
const { engine, db } = setup(server, EVERYTHING);
|
||||
await engine.requestSync();
|
||||
server.downloads.length = 0;
|
||||
engine.setScope({ ...EVERYTHING, maxTotalBytes: 150 });
|
||||
await engine.requestSync();
|
||||
expect(blobs(db)).toEqual(['b-newest']);
|
||||
expect(server.downloads).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts what another area keeps towards the total', async () => {
|
||||
const server = new Files();
|
||||
server.withFile('newest', 'b-newest', 100, 1);
|
||||
server.withFile('older', 'b-older', 100, 2);
|
||||
const filesArea: StoreArea = { name: 'files', migrations: [], methods: {}, syncers: [], keptBytes: () => 150 };
|
||||
const { engine, db } = setup(server, { ...EVERYTHING, maxTotalBytes: 250 }, [mailArea, filesArea]);
|
||||
await engine.requestSync();
|
||||
expect(blobs(db)).toEqual(['b-newest']);
|
||||
});
|
||||
|
||||
it('won\'t record a file the scope doesn\'t allow', async () => {
|
||||
const server = new Files();
|
||||
server.withFile('m', 'b-1', 10, 1);
|
||||
const { engine, db } = setup(server, { ...EVERYTHING, attachments: false });
|
||||
await engine.requestSync();
|
||||
expect(await engine.areaCall('mail.recordBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-1', type: null, data: new Uint8Array(10) })).toBe(false);
|
||||
expect(blobs(db)).toEqual([]);
|
||||
engine.setScope({ ...EVERYTHING, maxAttachmentBytes: 5 });
|
||||
expect(await engine.areaCall('mail.recordBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-1', type: null, data: new Uint8Array(10) })).toBe(false);
|
||||
engine.setScope(EVERYTHING);
|
||||
expect(await engine.areaCall('mail.recordBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-1', type: null, data: new Uint8Array(10) })).toBe(true);
|
||||
});
|
||||
|
||||
it('stops a pass under way, and the pass after it goes by the new scope', async () => {
|
||||
const server = new FakeJmap();
|
||||
for (let i = 0; i < 700; i += 1) server.addEmail({ receivedAt: iso(100) });
|
||||
server.addEmail({ id: 'new', receivedAt: iso(5) });
|
||||
const { engine, db } = setup(server, EVERYTHING);
|
||||
let tightened = false;
|
||||
server.beforeRequest = (n) => {
|
||||
if (n >= 3 && !tightened) {
|
||||
tightened = true;
|
||||
engine.setScope({ ...EVERYTHING, days: 30 });
|
||||
}
|
||||
};
|
||||
await engine.requestSync();
|
||||
expect(tightened).toBe(true);
|
||||
expect(ids(db)).toEqual(['new']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a scope changed while one is being applied', () => {
|
||||
it('fetches back what the first one already removed', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail({ id: 'old', receivedAt: iso(100) });
|
||||
for (let i = 0; i < 450; i += 1) server.addEmail({ id: `mid${i}`, receivedAt: iso(50) });
|
||||
server.addEmail({ id: 'new', receivedAt: iso(5) });
|
||||
const { engine, db } = setup(server, EVERYTHING);
|
||||
await engine.requestSync();
|
||||
|
||||
engine.setScope({ ...EVERYTHING, days: 30 });
|
||||
const run = engine.requestSync();
|
||||
// One batch in: the rows are going, and the change back arrives before the first is done.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(count(db, 'email')).toBeLessThan(452);
|
||||
engine.setScope(EVERYTHING);
|
||||
await run;
|
||||
await engine.requestSync();
|
||||
expect(count(db, 'email')).toBe(452);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a loosened scope', () => {
|
||||
it('starts a backfill of the wider window and keeps the bodies it has', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail({ id: 'old', receivedAt: iso(100) });
|
||||
server.addEmail({ id: 'new', receivedAt: iso(5) });
|
||||
const { engine, db } = setup(server, { ...EVERYTHING, days: 30 });
|
||||
await engine.requestSync();
|
||||
expect(ids(db)).toEqual(['new']);
|
||||
|
||||
server.calls.length = 0;
|
||||
engine.setScope({ ...EVERYTHING, days: 365 });
|
||||
await engine.requestSync();
|
||||
expect(ids(db)).toEqual(['new', 'old']);
|
||||
expect(count(db, 'email_body')).toBe(2);
|
||||
// The new message's body wasn't fetched again.
|
||||
const fetchedBodies = server.calls
|
||||
.filter(([name, args]) => name === 'Email/get' && (args as { fetchAllBodyValues?: boolean }).fetchAllBodyValues)
|
||||
.flatMap(([, args]) => (args as { ids: string[] }).ids);
|
||||
expect(fetchedBodies).toEqual(['old']);
|
||||
});
|
||||
|
||||
it('does nothing when the scope is the same', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail({ id: 'new', receivedAt: iso(5) });
|
||||
const { engine } = setup(server, { ...EVERYTHING, days: 30 });
|
||||
await engine.requestSync();
|
||||
expect(engine.setScope({ ...EVERYTHING, days: 30 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the calendar', () => {
|
||||
function calendarSetup(scope: StoreScope) {
|
||||
const server = new FakeCalendarJmap('1.0');
|
||||
const db = openMemoryStore();
|
||||
const engine = new AccountEngine({
|
||||
account: 'a@fake.test', db, session: CALENDAR_SESSION, transport: server, areas: [calendarArea], scope, now: () => NOW, sleep: async () => {},
|
||||
});
|
||||
return { server, db, engine };
|
||||
}
|
||||
|
||||
it('follows the window, and series and what is ahead stay', async () => {
|
||||
const { server, db, engine } = calendarSetup(EVERYTHING);
|
||||
server.addEvent({ id: 'old', start: '2026-06-01T10:00:00' });
|
||||
server.addEvent({ id: 'recent', start: '2026-10-30T10:00:00' });
|
||||
server.addEvent({ id: 'ahead', start: '2027-01-01T10:00:00' });
|
||||
server.addEvent({ id: 'series', start: '2015-01-01T10:00:00', recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' } });
|
||||
await engine.requestSync();
|
||||
expect(ids(db, 'calendar_event')).toEqual(['ahead', 'old', 'recent', 'series']);
|
||||
|
||||
engine.setOnline(false);
|
||||
engine.setScope({ ...EVERYTHING, days: 30 });
|
||||
await engine.requestSync();
|
||||
expect(ids(db, 'calendar_event')).toEqual(['ahead', 'recent', 'series']);
|
||||
|
||||
engine.setOnline(true);
|
||||
await engine.requestSync();
|
||||
expect(ids(db, 'calendar_event')).toEqual(['ahead', 'recent', 'series']);
|
||||
});
|
||||
|
||||
it('lists again when the window gets wider, since no change will name what was left out', async () => {
|
||||
const { server, db, engine } = calendarSetup({ ...EVERYTHING, days: 30 });
|
||||
server.addEvent({ id: 'old', start: '2026-06-01T10:00:00' });
|
||||
await engine.requestSync();
|
||||
expect(ids(db, 'calendar_event')).toEqual([]);
|
||||
engine.setScope({ ...EVERYTHING, days: 365 });
|
||||
await engine.requestSync();
|
||||
expect(ids(db, 'calendar_event')).toEqual(['old']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what the storage page reads', () => {
|
||||
it('says what each part takes, with the rest under mail, and doesn\'t count freed pages', async () => {
|
||||
const server = new Files();
|
||||
server.withFile('m', 'b-1', 50_000, 1);
|
||||
server.addEmail({ id: 'old', receivedAt: iso(100), subject: 'x'.repeat(40_000) } as never);
|
||||
const { engine } = setup(server, EVERYTHING);
|
||||
await engine.requestSync();
|
||||
const before = await engine.areaCall('store.usage', undefined) as { bytes: number; parts: Array<{ part: string; bytes: number; items: number }> };
|
||||
const parts = Object.fromEntries(before.parts.map((part) => [part.part, part]));
|
||||
// The file, and the raw messages an export reads.
|
||||
expect(parts.attachments.bytes).toBeGreaterThanOrEqual(50_000);
|
||||
expect(parts.mail.items).toBe(2);
|
||||
expect(parts.mail.bytes).toBe(before.bytes - parts.attachments.bytes);
|
||||
|
||||
engine.setScope({ ...EVERYTHING, days: 30, attachments: false });
|
||||
await engine.requestSync();
|
||||
const after = await engine.areaCall('store.usage', undefined) as typeof before;
|
||||
expect(after.bytes).toBeLessThan(before.bytes - 50_000);
|
||||
expect(after.parts.find((part) => part.part === 'attachments')?.items).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -165,8 +165,10 @@ export class WasmStorage {
|
||||
db.exec(`PRAGMA key = "raw:${keyHex}"`);
|
||||
try {
|
||||
db.selectValue('SELECT count(*) FROM sqlite_master');
|
||||
} catch {
|
||||
throw new StoreKeyError();
|
||||
} catch (error) {
|
||||
// Only "this isn't a database": a passing read error must not read as a lost file.
|
||||
if (error instanceof Error && /NOTADB|not a database/i.test(error.message)) throw new StoreKeyError();
|
||||
throw error;
|
||||
}
|
||||
const pageSize = Number(db.selectValue('PRAGMA page_size'));
|
||||
if (pageSize !== REQUIRED_PAGE_SIZE) throw new Error(`Refusing an unsafe page size: ${pageSize}`);
|
||||
|
||||
+47
-1
@@ -19,6 +19,11 @@ export interface StoreScope {
|
||||
/** The largest attachment kept, and the total for all of them. */
|
||||
maxAttachmentBytes: number;
|
||||
maxTotalBytes: number;
|
||||
/**
|
||||
* The organisation's cap on one file, when it set one. Unlike
|
||||
* `maxAttachmentBytes`, which is also the user's own choice, a pin obeys it.
|
||||
*/
|
||||
orgMaxFileBytes?: number | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_SCOPE: StoreScope = {
|
||||
@@ -31,6 +36,27 @@ export const DEFAULT_SCOPE: StoreScope = {
|
||||
/** The `area` of an error event for a write that failed: a full disk, the browser's quota. */
|
||||
export const STORAGE_ERROR_AREA = 'storage';
|
||||
|
||||
/** The scope changed, or a day passed and the window moved. `previous` is null on a store that never applied one. */
|
||||
export interface ScopeChange {
|
||||
previous: StoreScope | null;
|
||||
next: StoreScope;
|
||||
}
|
||||
|
||||
export type StoragePartName = 'mail' | 'attachments' | 'calendar' | 'contacts' | 'files';
|
||||
|
||||
/** One line of what an account's file holds. */
|
||||
export interface StoreUsagePart {
|
||||
part: StoragePartName;
|
||||
bytes: number;
|
||||
items: number;
|
||||
}
|
||||
|
||||
/** `store.usage`: what the file holds. `mail` gets what the other parts don't account for. */
|
||||
export interface StoreUsage {
|
||||
bytes: number;
|
||||
parts: StoreUsagePart[];
|
||||
}
|
||||
|
||||
export interface SyncStateRow {
|
||||
state: string | null;
|
||||
/** The newest state the server told us about, to know when a run is caught up. */
|
||||
@@ -46,7 +72,9 @@ export type StoreEvent =
|
||||
| { type: 'synced'; account: string; at: number }
|
||||
| { type: 'changed'; account: string; collections: string[] }
|
||||
| { type: 'created'; account: string; jmap: string; collection: string; ids: string[] }
|
||||
| { type: 'error'; account: string; area: string; message: string };
|
||||
| { type: 'error'; account: string; area: string; message: string }
|
||||
/** Its file and key are gone, whichever tab asked: opening it again makes a new one. */
|
||||
| { type: 'removed'; account: string };
|
||||
|
||||
export interface StoreContext {
|
||||
account: string;
|
||||
@@ -57,6 +85,12 @@ export interface StoreContext {
|
||||
emit(event: StoreEvent): void;
|
||||
/** Records of one type as this device shows them: see RecordOverlay. */
|
||||
overlay<T extends { id: string }>(jmap: string, type: string, records: T[]): T[];
|
||||
/** Attachments and files the other areas keep: `scope.maxTotalBytes` is for all of them together. */
|
||||
keptByOthers(area: string): number;
|
||||
/** Records of one type another area needs to stay, whatever the window says. */
|
||||
sparedRecords(jmap: string, type: string): string[];
|
||||
/** Starts a sync pass, or one more after the running pass. Resolves when they're done. */
|
||||
requestSync(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,6 +163,18 @@ export interface StoreArea {
|
||||
syncers: readonly Syncer[];
|
||||
calls?: Readonly<Record<string, AreaCall>>;
|
||||
overlay?: RecordOverlay;
|
||||
/**
|
||||
* Brings what's kept in line with the scope. Runs without the network and may
|
||||
* be interrupted: it runs again until it has finished, so it must be safe to
|
||||
* repeat. Whatever is fetched back goes through the next sync pass.
|
||||
*/
|
||||
applyScope?: (ctx: SyncContext, change: ScopeChange) => void | Promise<void>;
|
||||
/** For the storage page. */
|
||||
usage?: (ctx: Pick<StoreContext, 'db'>) => StoreUsagePart[];
|
||||
/** Attachments and file contents it holds: what counts towards `scope.maxTotalBytes`. */
|
||||
keptBytes?: (ctx: Pick<StoreContext, 'db'>) => number;
|
||||
/** Records it needs, stored by another area, that no window may take away. */
|
||||
spares?: (ctx: Pick<StoreContext, 'db'>, jmap: string, type: string) => string[];
|
||||
/** At start and when the scope changes: a tighter policy applies at once, not at the next sync. */
|
||||
scopeChanged?(ctx: StoreContext): void;
|
||||
}
|
||||
|
||||
+3
-1
@@ -2,7 +2,9 @@
|
||||
|
||||
import type { StoreArea } from './area';
|
||||
import { calendarArea } from './calendar';
|
||||
import { contactsArea } from './contacts';
|
||||
import { filesArea } from './files';
|
||||
import { mailArea } from './mail';
|
||||
import { outboxArea } from './outbox';
|
||||
|
||||
export const STORE_AREAS: readonly StoreArea[] = [mailArea, outboxArea, calendarArea];
|
||||
export const STORE_AREAS: readonly StoreArea[] = [mailArea, outboxArea, calendarArea, contactsArea, filesArea];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { StoreArea } from '../area';
|
||||
import { CALENDAR_METHODS } from './methods';
|
||||
import { CALENDAR_MIGRATIONS } from './schema';
|
||||
import { calendarSyncer, flushCalendar } from './sync';
|
||||
import { applyCalendarScope, calendarSyncer, flushCalendar } from './sync';
|
||||
import { listConflicts, resolveStandIns } from './writes';
|
||||
|
||||
export const calendarArea: StoreArea = {
|
||||
@@ -9,6 +9,11 @@ export const calendarArea: StoreArea = {
|
||||
migrations: CALENDAR_MIGRATIONS,
|
||||
methods: CALENDAR_METHODS,
|
||||
syncers: [calendarSyncer],
|
||||
applyScope: applyCalendarScope,
|
||||
usage: (ctx) => {
|
||||
const row = ctx.db.get<{ bytes: number | null; items: number }>('SELECT SUM(LENGTH(json)) AS bytes, COUNT(*) AS items FROM calendar_event');
|
||||
return [{ part: 'calendar', bytes: Number(row?.bytes ?? 0), items: Number(row?.items ?? 0) }];
|
||||
},
|
||||
calls: {
|
||||
'calendar.flush': (ctx) => flushCalendar(ctx),
|
||||
/** Edits the server hasn't got, for the question before a sign-out. */
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { isTaskLikeObject } from '@/lib/calendar-component-detection';
|
||||
import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances';
|
||||
import type { StoreContext, SyncContext, Syncer } from '../area';
|
||||
import type { ScopeChange, StoreContext, StoreScope, SyncContext, Syncer } from '../area';
|
||||
import { runChangesLoop } from '../engine/changes';
|
||||
import { metaGet, metaSet, putObject } from '../engine/core-schema';
|
||||
import { JmapMethodError, responseFor, TransportError } from '../engine/transport';
|
||||
@@ -88,10 +88,15 @@ async function syncCalendars(ctx: SyncContext, jmap: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** How far back single events are kept: as far as the scope says, never past the two years. */
|
||||
function pastWindowMs(scope: StoreScope): number {
|
||||
return scope.days === null ? PAST_WINDOW_MS : Math.min(PAST_WINDOW_MS, scope.days * 86_400_000);
|
||||
}
|
||||
|
||||
function inWindow(ctx: SyncContext, event: CalendarObject): boolean {
|
||||
if (isSeries(event) || isTaskLikeObject(event)) return true;
|
||||
const { end } = instantsOf(event, 'UTC');
|
||||
return end === null || end >= ctx.now() - PAST_WINDOW_MS;
|
||||
return end === null || end >= ctx.now() - pastWindowMs(ctx.scope);
|
||||
}
|
||||
|
||||
interface ListCursor { position: number; gen: number; captured: string }
|
||||
@@ -225,12 +230,28 @@ async function syncEvents(ctx: SyncContext, jmap: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
/** Single events that aged out of the window. The window moves on without any change on the server. */
|
||||
function pruneOld(ctx: SyncContext, jmap: string): void {
|
||||
ctx.db.run(
|
||||
function pruneOld(ctx: SyncContext, jmap: string): number {
|
||||
return ctx.db.run(
|
||||
`DELETE FROM calendar_event WHERE jmap = ? AND is_series = 0 AND is_task = 0 AND end_ms IS NOT NULL AND end_ms < ?
|
||||
AND NOT EXISTS (SELECT 1 FROM calendar_pending p WHERE p.jmap = calendar_event.jmap AND p.record_id = calendar_event.id)`,
|
||||
[jmap, ctx.now() - PAST_WINDOW_MS],
|
||||
);
|
||||
[jmap, ctx.now() - pastWindowMs(ctx.scope)],
|
||||
).changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tighter: old single events go now, without the server. Wider: they weren't
|
||||
* kept and no /changes will name them, so the next pass lists everything again.
|
||||
*/
|
||||
export function applyCalendarScope(ctx: SyncContext, { previous, next }: ScopeChange): void {
|
||||
const jmaps = ctx.db.all<{ jmap: string }>('SELECT DISTINCT jmap FROM calendar_event').map((row) => row.jmap);
|
||||
const removed = jmaps.reduce((sum, jmap) => sum + pruneOld(ctx, jmap), 0);
|
||||
if (removed > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] });
|
||||
if (!previous || pastWindowMs(next) <= pastWindowMs(previous)) return;
|
||||
ctx.db.transaction(() => {
|
||||
for (const { jmap } of ctx.db.all<{ jmap: string }>("SELECT jmap FROM sync_state WHERE type = 'CalendarEvent'")) {
|
||||
ctx.setSyncState(jmap, 'CalendarEvent', { state: null, phase: null, cursor: null });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+32
-18
@@ -5,14 +5,15 @@
|
||||
* configureStore, so the worker bundle stays free of them.
|
||||
*/
|
||||
|
||||
import { DEFAULT_SCOPE, type StoreEvent, type StoreScope } from '../area';
|
||||
import type { StoreEvent, StoreScope } from '../area';
|
||||
import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport';
|
||||
import { forgetAccountKey, getAccountRecord, knownStoreFiles, openAccountKey, type WrapLayer } from './keys';
|
||||
import { forgetAccountKey, getAccountRecord, knownStoreFiles, openAccountKey, STORE_OPENED_KEY, type WrapLayer } from './keys';
|
||||
import { StoreClient } from './store-client';
|
||||
|
||||
export interface StoreAccountInputs {
|
||||
session: StoreSession | null;
|
||||
auth: string | null;
|
||||
/** Left out while the organisation's policy isn't known: the store keeps the last one. */
|
||||
scope?: StoreScope;
|
||||
invertedKeywordSort: boolean | null;
|
||||
}
|
||||
@@ -25,6 +26,11 @@ export interface StorePageHooks {
|
||||
onEvent(event: StoreEvent): void;
|
||||
/** Wrap layers on top of the WebCrypto one (desktop keychain, app lock). */
|
||||
keyLayers?(): readonly WrapLayer[];
|
||||
/**
|
||||
* This tab just took over the store. Resolves when it has heard the
|
||||
* organisation's policy since: until then it sends no scope, which may be old.
|
||||
*/
|
||||
ownerTaken?(): Promise<void>;
|
||||
/** An account this device kept mail for has an empty store now: its key or its file went. */
|
||||
onStoreLost?(account: string): void;
|
||||
}
|
||||
@@ -38,6 +44,8 @@ export interface StoreBackend {
|
||||
refresh(account: string): void;
|
||||
answer(account: string, calls: MethodCall[]): Promise<JmapResponse | null>;
|
||||
area<T = unknown>(account: string, name: string, params?: unknown): Promise<T>;
|
||||
/** What the store may keep. A tightened scope prunes at once, also without a network. */
|
||||
setScope(account: string, scope: StoreScope): Promise<void>;
|
||||
sync(account: string): Promise<void>;
|
||||
noteStateChange(account: string, changed: Record<string, Record<string, string>>): Promise<void>;
|
||||
setOnline(online: boolean): Promise<void>;
|
||||
@@ -51,11 +59,7 @@ 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';
|
||||
export { STORE_OPENED_KEY };
|
||||
|
||||
function readOpenedAccounts(): string[] {
|
||||
try {
|
||||
@@ -86,8 +90,8 @@ class PageStore implements StoreBackend {
|
||||
* can start the open again, and the second try finds the key the first one made.
|
||||
*/
|
||||
private readonly keyJustMade = new Set<string>();
|
||||
/** What the owner's engine was last told to keep. */
|
||||
private readonly sentScope = new Map<string, string>();
|
||||
/** False from taking over the store until this tab has heard the policy again. */
|
||||
private scopeCurrent = true;
|
||||
|
||||
constructor(private readonly hooks: () => StorePageHooks | null) {}
|
||||
|
||||
@@ -103,10 +107,21 @@ class PageStore implements StoreBackend {
|
||||
buildId: process.env.NEXT_PUBLIC_BUILD_ID,
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'error' && event.area === 'store' && !event.account) this.broken = true;
|
||||
// Another tab cleared it: what this tab remembers opening is gone.
|
||||
if (event.type === 'removed') this.opening.delete(event.account);
|
||||
this.hooks()?.onEvent(event);
|
||||
},
|
||||
onOwner: () => {
|
||||
this.opening.clear();
|
||||
const hooks = this.hooks();
|
||||
if (hooks?.ownerTaken) {
|
||||
this.scopeCurrent = false;
|
||||
const heard = () => {
|
||||
this.scopeCurrent = true;
|
||||
for (const account of this.wanted.keys()) this.refresh(account);
|
||||
};
|
||||
void hooks.ownerTaken().then(heard, heard);
|
||||
}
|
||||
for (const account of this.wanted.keys()) void this.openHere(account).catch(() => {});
|
||||
},
|
||||
onNeedAuth: (account) => {
|
||||
@@ -123,7 +138,7 @@ class PageStore implements StoreBackend {
|
||||
|
||||
private reset(): void {
|
||||
this.client = null;
|
||||
this.sentScope.clear();
|
||||
this.scopeCurrent = true;
|
||||
this.wanted.clear();
|
||||
this.opening.clear();
|
||||
this.broken = false;
|
||||
@@ -140,13 +155,12 @@ class PageStore implements StoreBackend {
|
||||
// The old file, if the key alone went, can never be read again.
|
||||
if (this.keyJustMade.has(account)) await client.pruneFiles(await knownStoreFiles()).catch(() => {});
|
||||
const inputs = this.hooks()?.inputs(account) ?? { session: null, auth: null, invertedKeywordSort: null };
|
||||
if (inputs.scope) this.sentScope.set(account, JSON.stringify(inputs.scope));
|
||||
const opened = await client.openAccount({
|
||||
account,
|
||||
file: record.file,
|
||||
keyHex,
|
||||
session: inputs.session ?? this.wanted.get(account) ?? null,
|
||||
scope: inputs.scope ?? DEFAULT_SCOPE,
|
||||
scope: this.scopeCurrent ? inputs.scope : undefined,
|
||||
invertedKeywordSort: inputs.invertedKeywordSort,
|
||||
auth: inputs.auth,
|
||||
});
|
||||
@@ -175,12 +189,8 @@ class PageStore implements StoreBackend {
|
||||
if (!inputs) return;
|
||||
if (inputs.session) client.setSession(account, inputs.session);
|
||||
client.setAuth(account, inputs.auth);
|
||||
// Only a real change: setting it starts over what a full disk made the store give up on.
|
||||
const scope = inputs.scope ? JSON.stringify(inputs.scope) : null;
|
||||
if (inputs.scope && scope !== this.sentScope.get(account)) {
|
||||
if (this.sentScope.has(account)) client.setScope(account, inputs.scope);
|
||||
this.sentScope.set(account, scope!);
|
||||
}
|
||||
// The engine drops one that's no change.
|
||||
if (inputs.scope && this.scopeCurrent) void client.setScope(account, inputs.scope).catch(() => {});
|
||||
if (inputs.invertedKeywordSort !== null) client.setInvertedKeywordSort(account, inputs.invertedKeywordSort);
|
||||
}
|
||||
|
||||
@@ -206,6 +216,10 @@ class PageStore implements StoreBackend {
|
||||
return this.whenOpen(account, (client) => client.area<T>(account, name, params));
|
||||
}
|
||||
|
||||
setScope(account: string, scope: StoreScope): Promise<void> {
|
||||
return this.whenOpen(account, (client) => client.setScope(account, scope));
|
||||
}
|
||||
|
||||
sync(account: string): Promise<void> {
|
||||
return this.whenOpen(account, (client) => client.sync(account));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
* the record makes the file unreadable even if deleting it failed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
const DB_NAME = 'bulwark-store';
|
||||
const DB_VERSION = 1;
|
||||
const KEYS = 'keys';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { OWNER_LOCK, POOL_DIRECTORY, RPC_CHANNEL, type ChannelMessage } from '../worker/protocol';
|
||||
import { deleteKeyDatabase } from './keys';
|
||||
import { deleteKeyDatabase, STORE_OPENED_KEY } from './keys';
|
||||
|
||||
/** A worker just stopped may hold the files for a moment longer. */
|
||||
const REMOVE_TRIES = 10;
|
||||
@@ -51,6 +51,12 @@ export async function deleteStorePool(): Promise<void> {
|
||||
*/
|
||||
export function purgeStoreFiles(): Promise<void> {
|
||||
deleteKeyDatabase();
|
||||
// Deleted on purpose: no account lost anything. The next open is a first one.
|
||||
try {
|
||||
localStorage.removeItem(STORE_OPENED_KEY);
|
||||
} catch {
|
||||
// Nothing kept, nothing to forget.
|
||||
}
|
||||
try {
|
||||
const channel = new BroadcastChannel(RPC_CHANNEL);
|
||||
channel.postMessage({ kind: 'shutdown' } satisfies ChannelMessage);
|
||||
|
||||
@@ -284,10 +284,6 @@ export class StoreClient {
|
||||
this.post({ kind: 'set-session', account, session });
|
||||
}
|
||||
|
||||
setScope(account: string, scope: StoreScope): void {
|
||||
this.post({ kind: 'set-scope', account, scope });
|
||||
}
|
||||
|
||||
setInvertedKeywordSort(account: string, inverted: boolean): void {
|
||||
this.post({ kind: 'set-inverted', account, inverted });
|
||||
}
|
||||
@@ -332,6 +328,10 @@ export class StoreClient {
|
||||
return this.rpc('sync', { account }, SYNC_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
setScope(account: string, scope: StoreScope): Promise<void> {
|
||||
return this.rpc('setScope', { account, scope });
|
||||
}
|
||||
|
||||
removeAccount(account: string, file: string): Promise<boolean> {
|
||||
return this.rpc('removeAccount', { account, file });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { StoreArea } from '../area';
|
||||
import { CONTACT_METHODS } from './methods';
|
||||
import { listConflicts, pendingCount } from './pending';
|
||||
import { CONTACT_MIGRATIONS } from './schema';
|
||||
import { contactsSyncer } from './sync';
|
||||
|
||||
export const contactsArea: StoreArea = {
|
||||
name: 'contacts',
|
||||
migrations: CONTACT_MIGRATIONS,
|
||||
methods: CONTACT_METHODS,
|
||||
syncers: [contactsSyncer],
|
||||
usage: (ctx) => {
|
||||
const row = ctx.db.get<{ bytes: number | null; items: number }>('SELECT SUM(LENGTH(json)) AS bytes, COUNT(*) AS items FROM contact_card');
|
||||
return [{ part: 'contacts', bytes: Number(row?.bytes ?? 0), items: Number(row?.items ?? 0) }];
|
||||
},
|
||||
calls: {
|
||||
/** Sends what's queued, in the next sync pass, and says what's left. */
|
||||
'contacts.flush': async (ctx) => {
|
||||
await ctx.requestSync();
|
||||
return { pending: pendingCount(ctx.db), conflicts: listConflicts(ctx.db).length };
|
||||
},
|
||||
/** How many cards have edits waiting to go out. */
|
||||
'contacts.pending': (ctx) => pendingCount(ctx.db),
|
||||
/** Edits the server refused or that met a change there. The server's copy is the one kept. */
|
||||
'contacts.conflicts': (ctx) => listConflicts(ctx.db),
|
||||
/** The user has seen this one. */
|
||||
'contacts.dismissConflict': (ctx, params: { id: number }) => {
|
||||
ctx.db.run('DELETE FROM contact_conflict WHERE id = ?', [params.id]);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
/** Contact reads and queued edits, answered from the store. */
|
||||
|
||||
import { MethodError, type MethodContext, type MethodHandler, type MethodResult } from '../area';
|
||||
import { listObjects } from '../engine/core-schema';
|
||||
import type { SqlValue } from '../storage';
|
||||
import { queueCreate, queueDestroy, queueUpdate, resolveCardId, type SetError } from './pending';
|
||||
import { CONTACTS_CAPABILITY, foldText } from './records';
|
||||
import type { JsonObject, Patch } from './patch';
|
||||
|
||||
type Filter = Record<string, unknown>;
|
||||
|
||||
interface Sql { sql: string; params: SqlValue[] }
|
||||
|
||||
function jmapOf(ctx: MethodContext, args: Record<string, unknown>): string {
|
||||
return (args.accountId as string | undefined) ?? ctx.session.primaryAccounts[CONTACTS_CAPABILITY] ?? ctx.defaultJmap;
|
||||
}
|
||||
|
||||
/**
|
||||
* The last state from the server. Throws until a first full pass finished, so
|
||||
* an answer never passes off part of a list as the whole. A later resync
|
||||
* (phase `list`) goes on answering from the copy it's refreshing.
|
||||
*/
|
||||
function syncedState(ctx: MethodContext, jmap: string, type: string): string | null {
|
||||
const row = ctx.db.get<{ state: string | null; phase: string | null }>('SELECT state, phase FROM sync_state WHERE jmap = ? AND type = ?', [jmap, type]);
|
||||
if (!row || (!row.state && row.phase !== 'list')) throw new MethodError('serverUnavailable', 'Contacts not kept yet');
|
||||
return row.state;
|
||||
}
|
||||
|
||||
function pick(object: Record<string, unknown>, properties: unknown): Record<string, unknown> {
|
||||
if (!Array.isArray(properties)) return object;
|
||||
const out: Record<string, unknown> = { id: object.id };
|
||||
for (const property of properties as string[]) if (property in object) out[property] = object[property];
|
||||
return out;
|
||||
}
|
||||
|
||||
function quote(term: string): string {
|
||||
return `"${term.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function terms(text: string): string[] {
|
||||
return foldText(text).split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `text` condition, one part of the client's textFilter (search-utils.ts):
|
||||
* words, `word*` or a "quoted phrase". Every word has to match somewhere. The
|
||||
* trigram index finds it anywhere in a name, which takes three characters.
|
||||
* Shorter ones match the start of a word.
|
||||
*/
|
||||
function textCondition(value: string, rank: string[]): Sql {
|
||||
const phrase = /^"(.+)"$/.exec(value.trim());
|
||||
const words = terms(phrase ? phrase[1] : value);
|
||||
if (words.length === 0) return { sql: '0', params: [] };
|
||||
if (phrase) {
|
||||
rank.push(quote(words.join(' ')));
|
||||
return { sql: 'c.rowid IN (SELECT rowid FROM contact_fts_word WHERE contact_fts_word MATCH ?)', params: [quote(words.join(' '))] };
|
||||
}
|
||||
const parts: string[] = [];
|
||||
const params: SqlValue[] = [];
|
||||
for (const word of words) {
|
||||
rank.push(`${quote(word)}*`);
|
||||
if ([...word].length >= 3) {
|
||||
parts.push('c.rowid IN (SELECT rowid FROM contact_fts_tri WHERE contact_fts_tri MATCH ?)');
|
||||
params.push(quote(word));
|
||||
} else {
|
||||
parts.push('c.rowid IN (SELECT rowid FROM contact_fts_word WHERE contact_fts_word MATCH ?)');
|
||||
params.push(`${quote(word)}*`);
|
||||
}
|
||||
}
|
||||
return { sql: parts.join(' AND '), params };
|
||||
}
|
||||
|
||||
function filterSql(filter: Filter, rank: string[]): Sql {
|
||||
if (typeof filter.operator === 'string') {
|
||||
const parts = (Array.isArray(filter.conditions) ? filter.conditions as Filter[] : []).map((c) => filterSql(c, rank));
|
||||
const params = parts.flatMap((p) => p.params);
|
||||
const joined = (joiner: string) => parts.map((p) => `(${p.sql})`).join(joiner);
|
||||
if (filter.operator === 'AND') return { sql: parts.length ? joined(' AND ') : '1', params };
|
||||
if (filter.operator === 'OR') return { sql: parts.length ? joined(' OR ') : '0', params };
|
||||
if (filter.operator === 'NOT') return { sql: parts.length ? `NOT (${joined(' OR ')})` : '1', params };
|
||||
throw new MethodError('unsupportedFilter', `Unknown operator ${String(filter.operator)}`);
|
||||
}
|
||||
const conditions: string[] = [];
|
||||
const params: SqlValue[] = [];
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (key === 'text') {
|
||||
const text = textCondition(String(value), rank);
|
||||
conditions.push(text.sql);
|
||||
params.push(...text.params);
|
||||
} else if (key === 'inAddressBook') {
|
||||
conditions.push('EXISTS (SELECT 1 FROM contact_card_book b WHERE b.card_rowid = c.rowid AND b.book_id = ?)');
|
||||
params.push(String(value));
|
||||
} else {
|
||||
throw new MethodError('unsupportedFilter', `${key} isn't available offline`);
|
||||
}
|
||||
}
|
||||
return { sql: conditions.length ? conditions.join(' AND ') : '1', params };
|
||||
}
|
||||
|
||||
const addressBookGet: MethodHandler = (ctx, args) => {
|
||||
const jmap = jmapOf(ctx, args);
|
||||
const state = syncedState(ctx, jmap, 'AddressBook');
|
||||
const books = listObjects<Record<string, unknown>>(ctx.db, jmap, 'AddressBook');
|
||||
const ids = Array.isArray(args.ids) ? args.ids as string[] : null;
|
||||
const chosen = ids ? books.filter((book) => ids.includes(book.id as string)) : books;
|
||||
return {
|
||||
accountId: jmap,
|
||||
...(state ? { state } : {}),
|
||||
list: chosen.map((book) => pick(book, args.properties)),
|
||||
notFound: ids ? ids.filter((id) => !books.some((book) => book.id === id)) : [],
|
||||
};
|
||||
};
|
||||
|
||||
const contactQuery: MethodHandler = (ctx, args) => {
|
||||
const jmap = jmapOf(ctx, args);
|
||||
syncedState(ctx, jmap, 'ContactCard');
|
||||
if (args.anchor !== undefined) throw new MethodError('unsupportedFilter', 'anchor needs the server');
|
||||
if (Array.isArray(args.sort) && args.sort.length > 0) throw new MethodError('unsupportedSort', 'Sorted lists need the server');
|
||||
const rank: string[] = [];
|
||||
const where = args.filter ? filterSql(args.filter as Filter, rank) : { sql: '1', params: [] };
|
||||
const position = Math.max(0, Number(args.position ?? 0));
|
||||
// limit 0 means no limit on Stalwart, and the client can't tell a local answer apart.
|
||||
const limit = typeof args.limit === 'number' && args.limit > 0 ? args.limit : -1;
|
||||
// Names starting with what was typed come before ones merely containing it.
|
||||
const first = rank.length > 0 ? 'c.rowid IN (SELECT rowid FROM contact_fts_word WHERE contact_fts_word MATCH ?) DESC, ' : '';
|
||||
const ids = ctx.db.all<{ id: string }>(
|
||||
`SELECT c.id FROM contact_card c WHERE c.jmap = ? AND (${where.sql}) ORDER BY ${first}c.sort_key, c.id LIMIT ? OFFSET ?`,
|
||||
[jmap, ...where.params, ...(rank.length > 0 ? [rank.join(' AND ')] : []), limit, position],
|
||||
).map((row) => String(row.id));
|
||||
const result: MethodResult = {
|
||||
accountId: jmap,
|
||||
// Local answers aren't a position in the server's log, so this is a token only.
|
||||
queryState: 'local',
|
||||
canCalculateChanges: false,
|
||||
position,
|
||||
ids,
|
||||
};
|
||||
if (args.calculateTotal) {
|
||||
result.total = Number(ctx.db.get<{ n: number }>(
|
||||
`SELECT COUNT(*) AS n FROM contact_card c WHERE c.jmap = ? AND (${where.sql})`,
|
||||
[jmap, ...where.params],
|
||||
)?.n ?? 0);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const contactGet: MethodHandler = (ctx, args) => {
|
||||
const jmap = jmapOf(ctx, args);
|
||||
const state = syncedState(ctx, jmap, 'ContactCard');
|
||||
const list: Array<Record<string, unknown>> = [];
|
||||
const notFound: string[] = [];
|
||||
const rows = Array.isArray(args.ids)
|
||||
? (args.ids as string[]).map((id) => ({ id, row: ctx.db.get<{ json: string }>('SELECT json FROM contact_card WHERE jmap = ? AND id = ?', [jmap, resolveCardId(ctx.db, jmap, id)]) }))
|
||||
: ctx.db.all<{ json: string; id: string }>('SELECT json, id FROM contact_card WHERE jmap = ? ORDER BY sort_key, id', [jmap]).map((row) => ({ id: row.id, row }));
|
||||
for (const { id, row } of rows) {
|
||||
if (row) list.push(pick(JSON.parse(row.json) as Record<string, unknown>, args.properties));
|
||||
else notFound.push(id);
|
||||
}
|
||||
return { accountId: jmap, ...(state ? { state } : {}), list, notFound };
|
||||
};
|
||||
|
||||
/**
|
||||
* Queues the edits. The answer leaves out oldState and newState: nothing
|
||||
* reached the server, so there's no real state to name, and the client reads
|
||||
* neither from ContactCard/set.
|
||||
*/
|
||||
const contactSet: MethodHandler = (ctx, args) => {
|
||||
const jmap = jmapOf(ctx, args);
|
||||
syncedState(ctx, jmap, 'ContactCard');
|
||||
const created: Record<string, { id: string; uid: string }> = {};
|
||||
const notCreated: Record<string, SetError> = {};
|
||||
const updated: Record<string, null> = {};
|
||||
const notUpdated: Record<string, SetError> = {};
|
||||
const destroyed: string[] = [];
|
||||
const notDestroyed: Record<string, SetError> = {};
|
||||
ctx.db.transaction(() => {
|
||||
for (const [cid, card] of Object.entries((args.create ?? {}) as Record<string, JsonObject>)) {
|
||||
const outcome = queueCreate(ctx, jmap, card);
|
||||
if ('id' in outcome) created[cid] = outcome;
|
||||
else notCreated[cid] = outcome;
|
||||
}
|
||||
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, Patch>)) {
|
||||
const refused = queueUpdate(ctx, jmap, id, patch);
|
||||
if (refused) notUpdated[id] = refused;
|
||||
else updated[id] = null;
|
||||
}
|
||||
for (const id of (args.destroy ?? []) as string[]) {
|
||||
const refused = queueDestroy(ctx, jmap, id);
|
||||
if (refused) notDestroyed[id] = refused;
|
||||
else destroyed.push(id);
|
||||
}
|
||||
});
|
||||
if (Object.keys(created).length + Object.keys(updated).length + destroyed.length > 0) {
|
||||
ctx.emit({ type: 'changed', account: ctx.account, collections: ['ContactCard'] });
|
||||
}
|
||||
const some = (name: string, value: object) => (Object.keys(value).length > 0 ? { [name]: value } : {});
|
||||
return {
|
||||
accountId: jmap,
|
||||
...some('created', created),
|
||||
...some('notCreated', notCreated),
|
||||
...some('updated', updated),
|
||||
...some('notUpdated', notUpdated),
|
||||
...some('destroyed', destroyed),
|
||||
...some('notDestroyed', notDestroyed),
|
||||
};
|
||||
};
|
||||
|
||||
export const CONTACT_METHODS: Readonly<Record<string, MethodHandler>> = {
|
||||
'AddressBook/get': addressBookGet,
|
||||
'ContactCard/query': contactQuery,
|
||||
'ContactCard/get': contactGet,
|
||||
'ContactCard/set': contactSet,
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* RFC 8620 §5.3 patches: keys are JSON pointers without the leading slash, a
|
||||
* null value removes the property. Written so the common outbox can use the
|
||||
* same rules for its per-record merge.
|
||||
*/
|
||||
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
export type Patch = Record<string, unknown>;
|
||||
|
||||
export class InvalidPatchError extends Error {
|
||||
constructor(path: string) {
|
||||
super(`Can't apply a patch at ${path}`);
|
||||
this.name = 'InvalidPatchError';
|
||||
}
|
||||
}
|
||||
|
||||
function steps(path: string): string[] {
|
||||
return path.split('/').map((step) => step.replace(/~1/g, '/').replace(/~0/g, '~'));
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** A copy of the object with the patch applied. Throws when a path leads nowhere. */
|
||||
export function applyPatch<T extends JsonObject>(object: T, patch: Patch): T {
|
||||
const root: JsonObject = { ...object };
|
||||
for (const [path, value] of Object.entries(patch)) {
|
||||
const parts = steps(path);
|
||||
let node = root;
|
||||
for (const step of parts.slice(0, -1)) {
|
||||
const next = node[step];
|
||||
if (!isObject(next)) throw new InvalidPatchError(path);
|
||||
const copy = { ...next };
|
||||
node[step] = copy;
|
||||
node = copy;
|
||||
}
|
||||
const last = parts[parts.length - 1];
|
||||
if (value === null) delete node[last];
|
||||
else node[last] = value;
|
||||
}
|
||||
return root as T;
|
||||
}
|
||||
|
||||
/** One patch that does what the older one and then the newer one would do. */
|
||||
export function mergePatches(older: Patch, newer: Patch): Patch {
|
||||
const merged: Patch = { ...older };
|
||||
for (const [path, value] of Object.entries(newer)) {
|
||||
// A newer write to a whole property replaces older writes below it.
|
||||
for (const key of Object.keys(merged)) {
|
||||
if (key.startsWith(`${path}/`)) delete merged[key];
|
||||
}
|
||||
// A newer write below an older whole-property write goes into that value.
|
||||
const parent = Object.keys(merged).find((key) => path.startsWith(`${key}/`));
|
||||
if (parent && isObject(merged[parent])) {
|
||||
const holder = applyPatch({ value: merged[parent] }, { [`value/${path.slice(parent.length + 1)}`]: value });
|
||||
merged[parent] = holder.value;
|
||||
} else {
|
||||
merged[path] = value;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** The top-level properties a patch writes to. */
|
||||
export function touchedProperties(patch: Patch): string[] {
|
||||
return [...new Set(Object.keys(patch).map((path) => steps(path)[0]))];
|
||||
}
|
||||
|
||||
function isNothing(value: unknown): boolean {
|
||||
return value == null || (isObject(value) && Object.keys(value).length === 0);
|
||||
}
|
||||
|
||||
/** Deep equality that ignores key order. Absent, null and an empty map count as the same. */
|
||||
export function sameJson(a: unknown, b: unknown): boolean {
|
||||
if (a === b || (isNothing(a) && isNothing(b))) return true;
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
return a.length === b.length && a.every((item, i) => sameJson(item, b[i]));
|
||||
}
|
||||
if (isObject(a) && isObject(b)) {
|
||||
return [...new Set([...Object.keys(a), ...Object.keys(b)])].every((key) => sameJson(a[key], b[key]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Stalwart 1.0 keeps a data: URI in `media` as a blob: `uri` goes and `blobId` comes (measured). */
|
||||
function inlineKeptAsBlob(wanted: JsonObject, actual: JsonObject): boolean {
|
||||
return typeof wanted.uri === 'string' && wanted.uri.startsWith('data:') && actual.uri == null && typeof actual.blobId === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `actual` is `wanted` as far as a server can tell. A key only the
|
||||
* server has is its own, unless `base` shows it was there before the edit:
|
||||
* then the edit meant to remove it, and it hasn't happened yet.
|
||||
*/
|
||||
export function coversJson(wanted: unknown, actual: unknown, base?: unknown): boolean {
|
||||
if (!isObject(wanted) || !isObject(actual)) return sameJson(wanted, actual);
|
||||
const before = isObject(base) ? base : {};
|
||||
const inline = inlineKeptAsBlob(wanted, actual);
|
||||
for (const key of Object.keys(wanted)) {
|
||||
if (inline && key === 'uri') continue;
|
||||
if (!coversJson(wanted[key], actual[key], before[key])) return false;
|
||||
}
|
||||
for (const key of Object.keys(actual)) {
|
||||
if (key in wanted || (inline && key === 'blobId')) continue;
|
||||
if (before[key] != null) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* Contact edits made while the server can't be reached, and sending them.
|
||||
*
|
||||
* One row per card, whatever number of edits it had. The visible card in
|
||||
* contact_card already has the edits on top, and sync keeps it so. Sending
|
||||
* runs at the start of each sync pass, so it never overlaps with itself.
|
||||
*
|
||||
* What a row becomes on its way out:
|
||||
* - Update: the server's card is read first. Nothing to do if it already has
|
||||
* the values (a lost response). A conflict if the server changed one of the
|
||||
* properties the edit touches. Otherwise the patch goes out.
|
||||
* - Destroy: done if the card is gone, a conflict if it changed since.
|
||||
* - Create: a request that may have reached the server looks for the card's
|
||||
* uid first, so a lost response doesn't make a second card. Stalwart also
|
||||
* refuses a second card with the same uid. Either way the card found is only
|
||||
* ours if it holds what was sent, or an offline card with someone else's uid
|
||||
* would silently turn into theirs.
|
||||
*
|
||||
* The local copy after sending is always the server's own read-back, never what
|
||||
* was sent: Stalwart 1.0 stores a data: URI as a blob. A base built from what
|
||||
* was sent would read that as someone else's change. If the network goes before
|
||||
* the read-back, that counts as a lost response: the row stays, and the next
|
||||
* pass finds its edit already there. `sent` is what the last request held, so
|
||||
* the next pass can tell its own edit from someone else's.
|
||||
*/
|
||||
|
||||
import type { StoreContext, SyncContext } from '../area';
|
||||
import { requestLimits, type RequestLimits } from '../engine/limits';
|
||||
import { JmapMethodError, responseFor, TransportError, type MethodCall } from '../engine/transport';
|
||||
import type { StorageAdapter } from '../storage';
|
||||
import { applyPatch, coversJson, InvalidPatchError, mergePatches, sameJson, touchedProperties, type JsonObject, type Patch } from './patch';
|
||||
import { cardDisplayName, CONTACTS_USING, readCard, removeCard, writeCard, type Card } from './records';
|
||||
|
||||
export const STAND_IN_PREFIX = 'bulwark-local-card-';
|
||||
/** What a held upload's stand-in blob id starts with (lib/offline/queue.ts). */
|
||||
const LOCAL_BLOB_MARK = '"blobId":"bulwark-local-';
|
||||
|
||||
const SET_BATCH = 100;
|
||||
|
||||
/** Of the session's request limit. A bigger card would never go out. */
|
||||
function maxRecordBytes(ctx: StoreContext): number {
|
||||
return Math.floor(requestLimits(ctx.session).maxSizeRequest * 0.8);
|
||||
}
|
||||
|
||||
/** Set errors that say nothing about the edit itself: it goes out again next time. */
|
||||
const TRANSIENT_ERRORS = new Set(['serverFail', 'serverUnavailable', 'rateLimit']);
|
||||
/** Server-kept timestamps: their changing doesn't mean someone edited the card. */
|
||||
const SERVER_STAMPS = ['updated', 'created'];
|
||||
|
||||
export interface PendingRow {
|
||||
seq: number;
|
||||
jmap: string;
|
||||
id: string;
|
||||
op: 'create' | 'update' | 'destroy';
|
||||
uid: string | null;
|
||||
payload: string | null;
|
||||
base: string | null;
|
||||
attempted: number;
|
||||
sent: string | null;
|
||||
queued_at: number;
|
||||
}
|
||||
|
||||
export interface SetError { type: string; description?: string; properties?: string[] }
|
||||
|
||||
export interface ContactConflict {
|
||||
id: number;
|
||||
jmap: string;
|
||||
recordId: string;
|
||||
kind: 'refused' | 'changed' | 'gone';
|
||||
summary: string;
|
||||
local: { op: string; payload: unknown };
|
||||
server: unknown;
|
||||
error: string | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export function pendingFor(db: StorageAdapter, jmap: string, id: string): PendingRow | undefined {
|
||||
return db.get<PendingRow>('SELECT * FROM contact_pending WHERE jmap = ? AND id = ?', [jmap, id]);
|
||||
}
|
||||
|
||||
function dropPending(db: StorageAdapter, jmap: string, id: string): void {
|
||||
db.run('DELETE FROM contact_pending WHERE jmap = ? AND id = ?', [jmap, id]);
|
||||
}
|
||||
|
||||
function savePending(db: StorageAdapter, ctx: { now(): number }, row: { jmap: string; id: string; op: PendingRow['op']; uid?: string | null; payload: string | null; base: string | null }): void {
|
||||
db.run(
|
||||
`INSERT INTO contact_pending (jmap, id, op, uid, payload, base, queued_at) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (jmap, id) DO UPDATE SET op = excluded.op, uid = excluded.uid, payload = excluded.payload, base = excluded.base`,
|
||||
[row.jmap, row.id, row.op, row.uid ?? null, row.payload, row.base, ctx.now()],
|
||||
);
|
||||
}
|
||||
|
||||
/** A stand-in id that already went through becomes the real one. */
|
||||
export function resolveCardId(db: StorageAdapter, jmap: string, id: string): string {
|
||||
return db.get<{ real_id: string }>('SELECT real_id FROM contact_id_map WHERE jmap = ? AND local_id = ?', [jmap, id])?.real_id ?? id;
|
||||
}
|
||||
|
||||
/** A held upload can't be named in a card that goes out later. The client falls back to a data: URI. */
|
||||
function holdsLocalBlob(value: unknown): boolean {
|
||||
return JSON.stringify(value).includes(LOCAL_BLOB_MARK);
|
||||
}
|
||||
|
||||
const TOO_LARGE_ERROR: SetError = { type: 'tooLarge', description: 'This contact is too large to save' };
|
||||
|
||||
const LOCAL_BLOB_ERROR: SetError = {
|
||||
type: 'invalidProperties',
|
||||
description: 'A photo that is not on the server yet cannot be used here',
|
||||
properties: ['media'],
|
||||
};
|
||||
|
||||
export function queueCreate(ctx: StoreContext, jmap: string, card: JsonObject): { id: string; uid: string } | SetError {
|
||||
if (holdsLocalBlob(card)) return LOCAL_BLOB_ERROR;
|
||||
if (Object.keys((card.addressBookIds ?? {}) as object).length === 0) {
|
||||
// The client picks the default book. None known means there's nowhere to put it.
|
||||
return { type: 'invalidProperties', description: 'A contact needs an address book', properties: ['addressBookIds'] };
|
||||
}
|
||||
const id = `${STAND_IN_PREFIX}${crypto.randomUUID()}`;
|
||||
const uid = typeof card.uid === 'string' && card.uid ? card.uid : `urn:uuid:${crypto.randomUUID()}`;
|
||||
const payload = JSON.stringify({ ...card, uid });
|
||||
if (payload.length > maxRecordBytes(ctx)) return TOO_LARGE_ERROR;
|
||||
savePending(ctx.db, ctx, { jmap, id, op: 'create', uid, payload, base: null });
|
||||
writeCard(ctx.db, jmap, { '@type': 'Card', version: '1.0', ...card, uid, id });
|
||||
return { id, uid };
|
||||
}
|
||||
|
||||
export function queueUpdate(ctx: StoreContext, jmap: string, rawId: string, patch: Patch): SetError | null {
|
||||
const id = resolveCardId(ctx.db, jmap, rawId);
|
||||
const card = readCard(ctx.db, jmap, id);
|
||||
if (!card) return { type: 'notFound' };
|
||||
if ('id' in patch) return { type: 'invalidProperties', description: 'The id property is immutable.', properties: ['id'] };
|
||||
if (holdsLocalBlob(patch)) return LOCAL_BLOB_ERROR;
|
||||
const pending = pendingFor(ctx.db, jmap, id);
|
||||
let visible: Card;
|
||||
try {
|
||||
visible = applyPatch(card, patch);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidPatchError) return { type: 'invalidPatch', description: error.message };
|
||||
throw error;
|
||||
}
|
||||
if (pending?.op === 'create') {
|
||||
const payload = JSON.stringify(applyPatch(JSON.parse(pending.payload as string) as JsonObject, patch));
|
||||
if (payload.length > maxRecordBytes(ctx)) return TOO_LARGE_ERROR;
|
||||
savePending(ctx.db, ctx, { jmap, id, op: 'create', uid: pending.uid, payload, base: null });
|
||||
} else {
|
||||
const merged = JSON.stringify(pending ? mergePatches(JSON.parse(pending.payload as string) as Patch, patch) : patch);
|
||||
if (merged.length > maxRecordBytes(ctx)) return TOO_LARGE_ERROR;
|
||||
savePending(ctx.db, ctx, { jmap, id, op: 'update', payload: merged, base: pending ? pending.base : JSON.stringify(card) });
|
||||
}
|
||||
writeCard(ctx.db, jmap, visible);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function queueDestroy(ctx: StoreContext, jmap: string, rawId: string): SetError | null {
|
||||
const id = resolveCardId(ctx.db, jmap, rawId);
|
||||
const card = readCard(ctx.db, jmap, id);
|
||||
if (!card) return { type: 'notFound' };
|
||||
const pending = pendingFor(ctx.db, jmap, id);
|
||||
if (pending?.op === 'create' && !pending.attempted) {
|
||||
// Never reached the server, so it never has to.
|
||||
dropPending(ctx.db, jmap, id);
|
||||
} else if (pending?.op === 'create') {
|
||||
// It may have reached the server. Sending decides, then destroys it.
|
||||
savePending(ctx.db, ctx, { jmap, id, op: 'destroy', uid: pending.uid, payload: pending.payload, base: null });
|
||||
} else {
|
||||
savePending(ctx.db, ctx, { jmap, id, op: 'destroy', payload: null, base: pending ? pending.base : JSON.stringify(card) });
|
||||
}
|
||||
removeCard(ctx.db, jmap, id);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- What sync does around queued edits ----
|
||||
|
||||
/** Stores a card the server sent, with whatever is queued for it on top. */
|
||||
export function storeServerCard(db: StorageAdapter, jmap: string, card: Card, gen?: number): void {
|
||||
const pending = pendingFor(db, jmap, card.id);
|
||||
if (pending?.op === 'destroy') return;
|
||||
let visible = card;
|
||||
if (pending?.op === 'update') {
|
||||
try {
|
||||
visible = applyPatch(card, JSON.parse(pending.payload as string) as Patch);
|
||||
} catch (error) {
|
||||
// The patch no longer fits the card. Sending turns this into a conflict.
|
||||
if (!(error instanceof InvalidPatchError)) throw error;
|
||||
}
|
||||
}
|
||||
writeCard(db, jmap, visible, gen);
|
||||
}
|
||||
|
||||
/** The server no longer has this card. */
|
||||
export function serverDestroyed(ctx: StoreContext, jmap: string, id: string): void {
|
||||
const pending = pendingFor(ctx.db, jmap, id);
|
||||
if (pending?.op === 'update') recordConflict(ctx, pending, 'gone', null, null);
|
||||
if (pending && pending.op !== 'create') dropPending(ctx.db, jmap, id);
|
||||
removeCard(ctx.db, jmap, id);
|
||||
}
|
||||
|
||||
function recordConflict(ctx: StoreContext, row: PendingRow, kind: ContactConflict['kind'], server: Card | null, error: SetError | null): void {
|
||||
const local = row.payload ? JSON.parse(row.payload) as JsonObject : null;
|
||||
const known = server ?? readCard(ctx.db, row.jmap, row.id) ?? (row.base ? JSON.parse(row.base) as JsonObject : local);
|
||||
ctx.db.run(
|
||||
'INSERT INTO contact_conflict (jmap, record_id, kind, summary, local, server, error, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
row.jmap,
|
||||
row.id,
|
||||
kind,
|
||||
cardDisplayName(known ?? {}),
|
||||
JSON.stringify({ op: row.op, payload: local }),
|
||||
server ? JSON.stringify(server) : null,
|
||||
error ? [error.type, error.description].filter(Boolean).join(': ') : null,
|
||||
ctx.now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function listConflicts(db: StorageAdapter): ContactConflict[] {
|
||||
return db.all<{ id: number; jmap: string; record_id: string; kind: ContactConflict['kind']; summary: string; local: string; server: string | null; error: string | null; created_at: number }>(
|
||||
'SELECT * FROM contact_conflict ORDER BY id',
|
||||
).map((row) => ({
|
||||
id: Number(row.id),
|
||||
jmap: row.jmap,
|
||||
recordId: row.record_id,
|
||||
kind: row.kind,
|
||||
summary: row.summary,
|
||||
local: JSON.parse(row.local) as ContactConflict['local'],
|
||||
server: row.server ? JSON.parse(row.server) : null,
|
||||
error: row.error,
|
||||
createdAt: Number(row.created_at),
|
||||
}));
|
||||
}
|
||||
|
||||
export function pendingCount(db: StorageAdapter): number {
|
||||
return Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM contact_pending')?.n ?? 0);
|
||||
}
|
||||
|
||||
// ---- Sending ----
|
||||
|
||||
function withoutStamps(card: JsonObject): JsonObject {
|
||||
const copy = { ...card };
|
||||
for (const key of SERVER_STAMPS) delete copy[key];
|
||||
return copy;
|
||||
}
|
||||
|
||||
async function readServerCards(ctx: SyncContext, jmap: string, ids: string[], limits: RequestLimits): Promise<Map<string, Card>> {
|
||||
const cards = new Map<string, Card>();
|
||||
for (let i = 0; i < ids.length; i += limits.maxObjectsInGet) {
|
||||
const response = await ctx.transport.request([['ContactCard/get', { accountId: jmap, ids: ids.slice(i, i + limits.maxObjectsInGet) }, 'g']], CONTACTS_USING);
|
||||
for (const card of responseFor(response, 'g')[1].list as Card[]) cards.set(card.id, card);
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
/** The server's cards with these uids. */
|
||||
async function cardsByUid(ctx: SyncContext, jmap: string, uids: string[], limits: RequestLimits): Promise<Map<string, Card>> {
|
||||
const ids = new Map<string, string>();
|
||||
for (let i = 0; i < uids.length; i += limits.maxCallsInRequest) {
|
||||
const group = uids.slice(i, i + limits.maxCallsInRequest);
|
||||
const calls: MethodCall[] = group.map((uid, n) => ['ContactCard/query', { accountId: jmap, filter: { uid }, limit: 1 }, `q${n}`]);
|
||||
const response = await ctx.transport.request(calls, CONTACTS_USING);
|
||||
group.forEach((uid, n) => {
|
||||
const found = responseFor(response, `q${n}`)[1].ids as string[];
|
||||
if (found.length > 0) ids.set(uid, found[0]);
|
||||
});
|
||||
}
|
||||
const cards = await readServerCards(ctx, jmap, [...ids.values()], limits);
|
||||
const byUid = new Map<string, Card>();
|
||||
for (const [uid, id] of ids) {
|
||||
const card = cards.get(id);
|
||||
if (card) byUid.set(uid, card);
|
||||
}
|
||||
return byUid;
|
||||
}
|
||||
|
||||
/** Whether a card the server has under our uid is the one we sent. */
|
||||
function holdsSent(sentPayload: string, card: Card): boolean {
|
||||
const sent = JSON.parse(sentPayload) as JsonObject;
|
||||
return Object.keys(sent).every((key) => coversJson(sent[key], card[key]));
|
||||
}
|
||||
|
||||
const UID_TAKEN: SetError = { type: 'invalidProperties', description: 'Another contact already has this uid', properties: ['uid'] };
|
||||
|
||||
/** The create went through, or had already. The stand-in row becomes the server's card. */
|
||||
function settleCreated(ctx: SyncContext, sent: PendingRow, server: Card): void {
|
||||
const { db } = ctx;
|
||||
const { jmap, id: standIn } = sent;
|
||||
const now = pendingFor(db, jmap, standIn);
|
||||
db.run('INSERT OR REPLACE INTO contact_id_map (jmap, local_id, real_id) VALUES (?, ?, ?)', [jmap, standIn, server.id]);
|
||||
dropPending(db, jmap, standIn);
|
||||
removeCard(db, jmap, standIn);
|
||||
if (now?.op === 'destroy' || !now) {
|
||||
// Deleted while the create was on its way: delete the real one next.
|
||||
savePending(db, ctx, { jmap, id: server.id, op: 'destroy', payload: null, base: JSON.stringify(server) });
|
||||
return;
|
||||
}
|
||||
if (now.payload !== sent.payload) {
|
||||
// Edited while the create was on its way: what differs goes out as an edit.
|
||||
const before = JSON.parse(sent.payload as string) as JsonObject;
|
||||
const after = JSON.parse(now.payload as string) as JsonObject;
|
||||
const patch: Patch = {};
|
||||
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||||
if (!sameJson(before[key], after[key])) patch[key.replace(/~/g, '~0').replace(/\//g, '~1')] = after[key] ?? null;
|
||||
}
|
||||
savePending(db, ctx, { jmap, id: server.id, op: 'update', payload: JSON.stringify(patch), base: JSON.stringify(server) });
|
||||
writeCard(db, jmap, applyPatch(server, patch));
|
||||
return;
|
||||
}
|
||||
writeCard(db, jmap, server);
|
||||
}
|
||||
|
||||
interface Decision { create: Map<string, PendingRow>; update: Map<string, PendingRow>; destroy: Map<string, PendingRow> }
|
||||
|
||||
/**
|
||||
* Whether someone else moved one of these properties since the edit was queued.
|
||||
* The server holding what our last request sent is our own doing, not a change:
|
||||
* its response may have been lost.
|
||||
*/
|
||||
function movedOnServer(row: PendingRow, server: Card, keys: readonly string[]): boolean {
|
||||
if (!row.base) return false;
|
||||
const base = JSON.parse(row.base) as JsonObject;
|
||||
let own: JsonObject | null = null;
|
||||
if (row.sent) {
|
||||
try {
|
||||
own = applyPatch(base, JSON.parse(row.sent) as Patch);
|
||||
} catch (error) {
|
||||
if (!(error instanceof InvalidPatchError)) throw error;
|
||||
}
|
||||
}
|
||||
return keys.some((key) => !sameJson(base[key], server[key]) && !(own && coversJson(own[key], server[key], base[key])));
|
||||
}
|
||||
|
||||
/** What to do with one row now that the server's copy is known. Conflicts are settled here. */
|
||||
function decide(ctx: SyncContext, row: PendingRow, server: Card | undefined, serverByUid: Map<string, Card>, out: Decision): void {
|
||||
const { db } = ctx;
|
||||
const { jmap, id } = row;
|
||||
const uidHit = row.uid ? serverByUid.get(row.uid) : undefined;
|
||||
if (row.op === 'create') {
|
||||
if (!uidHit) {
|
||||
out.create.set(id, row);
|
||||
} else if (holdsSent(row.sent ?? (row.payload as string), uidHit)) {
|
||||
settleCreated(ctx, { ...row, payload: row.sent ?? row.payload }, uidHit);
|
||||
} else {
|
||||
recordConflict(ctx, row, 'refused', null, UID_TAKEN);
|
||||
dropPending(db, jmap, id);
|
||||
removeCard(db, jmap, id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (row.op === 'destroy' && id.startsWith(STAND_IN_PREFIX)) {
|
||||
// A create that may have gone out, deleted since. Only a card holding what was sent is ours to delete.
|
||||
dropPending(db, jmap, id);
|
||||
const sent = row.sent ?? row.payload;
|
||||
if (uidHit && sent && holdsSent(sent, uidHit)) {
|
||||
savePending(db, ctx, { jmap, id: uidHit.id, op: 'destroy', payload: null, base: JSON.stringify(uidHit) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (row.op === 'destroy') {
|
||||
if (!server) {
|
||||
dropPending(db, jmap, id);
|
||||
return;
|
||||
}
|
||||
const stable = withoutStamps(server);
|
||||
const base = row.base ? withoutStamps(JSON.parse(row.base) as JsonObject) : {};
|
||||
if (movedOnServer(row, server, [...new Set([...Object.keys(stable), ...Object.keys(base)])])) {
|
||||
recordConflict(ctx, row, 'changed', server, null);
|
||||
dropPending(db, jmap, id);
|
||||
writeCard(db, jmap, server);
|
||||
} else {
|
||||
out.destroy.set(id, row);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!server) {
|
||||
recordConflict(ctx, row, 'gone', null, null);
|
||||
dropPending(db, jmap, id);
|
||||
removeCard(db, jmap, id);
|
||||
return;
|
||||
}
|
||||
const patch = JSON.parse(row.payload as string) as Patch;
|
||||
const base = row.base ? JSON.parse(row.base) as JsonObject : {};
|
||||
let applied: Card | null = null;
|
||||
try {
|
||||
applied = applyPatch(server, patch);
|
||||
} catch (error) {
|
||||
if (!(error instanceof InvalidPatchError)) throw error;
|
||||
}
|
||||
const touched = touchedProperties(patch);
|
||||
if (applied && touched.every((key) => coversJson((applied as Card)[key], server[key], base[key]))) {
|
||||
dropPending(db, jmap, id);
|
||||
writeCard(db, jmap, server);
|
||||
return;
|
||||
}
|
||||
if (!applied || movedOnServer(row, server, touched)) {
|
||||
recordConflict(ctx, row, 'changed', server, null);
|
||||
dropPending(db, jmap, id);
|
||||
writeCard(db, jmap, server);
|
||||
return;
|
||||
}
|
||||
out.update.set(id, row);
|
||||
}
|
||||
|
||||
function settleUpdated(ctx: SyncContext, sent: PendingRow, server: Card | undefined): void {
|
||||
const { db } = ctx;
|
||||
const now = pendingFor(db, sent.jmap, sent.id);
|
||||
if (!now) return;
|
||||
if (now.op === 'update' && now.payload === sent.payload) {
|
||||
dropPending(db, sent.jmap, sent.id);
|
||||
if (server) writeCard(db, sent.jmap, server);
|
||||
return;
|
||||
}
|
||||
// Edited again while this was on its way: that edit now builds on what the server holds.
|
||||
if (!server) return;
|
||||
db.run('UPDATE contact_pending SET base = ?, sent = NULL, attempted = 0 WHERE seq = ?', [JSON.stringify(server), now.seq]);
|
||||
if (now.op === 'update') storeServerCard(db, sent.jmap, server);
|
||||
}
|
||||
|
||||
function settleRefused(ctx: SyncContext, sent: PendingRow, error: SetError, server: Card | undefined): void {
|
||||
const { db } = ctx;
|
||||
if (TRANSIENT_ERRORS.has(error.type)) return;
|
||||
const now = pendingFor(db, sent.jmap, sent.id);
|
||||
if (now && (now.op !== sent.op || now.payload !== sent.payload)) {
|
||||
// Edited again while this was on its way, so the refused edit is still in
|
||||
// the row. It goes out once more, and a second refusal is the conflict.
|
||||
db.run('UPDATE contact_pending SET sent = NULL, attempted = 0 WHERE seq = ?', [now.seq]);
|
||||
return;
|
||||
}
|
||||
if (sent.op === 'destroy' && error.type === 'notFound') {
|
||||
dropPending(db, sent.jmap, sent.id);
|
||||
return;
|
||||
}
|
||||
const kind = error.type === 'notFound' ? 'gone' : 'refused';
|
||||
recordConflict(ctx, sent, kind, server ?? null, error);
|
||||
dropPending(db, sent.jmap, sent.id);
|
||||
const restore = server ?? (sent.base ? JSON.parse(sent.base) as Card : null);
|
||||
if (restore && kind === 'refused') writeCard(db, sent.jmap, restore);
|
||||
else removeCard(db, sent.jmap, sent.id);
|
||||
}
|
||||
|
||||
/** Takes rows up to about the size of one request. */
|
||||
function nextBatch(db: StorageAdapter, jmap: string, afterSeq: number, limits: RequestLimits): PendingRow[] {
|
||||
const rows = db.all<PendingRow>('SELECT * FROM contact_pending WHERE jmap = ? AND seq > ? ORDER BY seq LIMIT ?', [jmap, afterSeq, Math.min(SET_BATCH, limits.maxObjectsInSet)]);
|
||||
const room = limits.maxSizeRequest / 2;
|
||||
let bytes = 0;
|
||||
const batch: PendingRow[] = [];
|
||||
for (const row of rows) {
|
||||
bytes += row.payload?.length ?? 0;
|
||||
if (batch.length > 0 && bytes > room) break;
|
||||
batch.push(row);
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
|
||||
/** One exchange with the server for these rows: read, decide, send, read back, settle. */
|
||||
async function exchange(ctx: SyncContext, jmap: string, rows: readonly PendingRow[], limits: RequestLimits): Promise<void> {
|
||||
const { db } = ctx;
|
||||
const current = await readServerCards(ctx, jmap, rows.filter((row) => row.op !== 'create' && !row.id.startsWith(STAND_IN_PREFIX)).map((row) => row.id), limits);
|
||||
const serverByUid = await cardsByUid(ctx, jmap, rows.filter((row) => row.uid && row.attempted).map((row) => row.uid as string), limits);
|
||||
|
||||
const out: Decision = { create: new Map(), update: new Map(), destroy: new Map() };
|
||||
db.transaction(() => {
|
||||
for (const { seq, id } of rows) {
|
||||
// The page may have edited it while the reads were on their way.
|
||||
const row = pendingFor(db, jmap, id);
|
||||
if (row && row.seq === seq) decide(ctx, row, current.get(id), serverByUid, out);
|
||||
}
|
||||
for (const row of [...out.create.values(), ...out.update.values()]) {
|
||||
db.run('UPDATE contact_pending SET attempted = 1, sent = payload WHERE seq = ?', [row.seq]);
|
||||
}
|
||||
});
|
||||
if (out.create.size + out.update.size + out.destroy.size === 0) return;
|
||||
|
||||
const creationIds = new Map([...out.create.values()].map((row) => [`c${row.seq}`, row]));
|
||||
const response = await ctx.transport.request([['ContactCard/set', {
|
||||
accountId: jmap,
|
||||
...(creationIds.size > 0 ? { create: Object.fromEntries([...creationIds].map(([cid, row]) => [cid, JSON.parse(row.payload as string)])) } : {}),
|
||||
...(out.update.size > 0 ? { update: Object.fromEntries([...out.update.values()].map((row) => [row.id, JSON.parse(row.payload as string)])) } : {}),
|
||||
...(out.destroy.size > 0 ? { destroy: [...out.destroy.keys()] } : {}),
|
||||
}, 's']], CONTACTS_USING);
|
||||
const [, result] = responseFor(response, 's');
|
||||
|
||||
// What the server now holds. A network failure here is a lost response: the rows stay.
|
||||
const createdIds = new Map<string, string>();
|
||||
for (const cid of creationIds.keys()) {
|
||||
const created = result.created?.[cid] as { id?: unknown } | undefined;
|
||||
if (created?.id) createdIds.set(cid, String(created.id));
|
||||
}
|
||||
const updatedIds = [...out.update.keys()].filter((id) => result.updated && id in result.updated);
|
||||
const readBack = await readServerCards(ctx, jmap, [...createdIds.values(), ...updatedIds], limits);
|
||||
// A card already has this uid: maybe this one, sent before.
|
||||
const uidRefused = [...creationIds].filter(([cid]) => (result.notCreated?.[cid] as SetError | undefined)?.properties?.includes('uid'));
|
||||
const taken = await cardsByUid(ctx, jmap, uidRefused.map(([, row]) => row.uid as string), limits);
|
||||
|
||||
db.transaction(() => {
|
||||
for (const [cid, row] of creationIds) {
|
||||
const realId = createdIds.get(cid);
|
||||
const refused = result.notCreated?.[cid] as SetError | undefined;
|
||||
if (realId) {
|
||||
const card = readBack.get(realId);
|
||||
if (card) {
|
||||
settleCreated(ctx, row, card);
|
||||
} else {
|
||||
// Created and deleted again already. The delta will say the same.
|
||||
dropPending(db, jmap, row.id);
|
||||
removeCard(db, jmap, row.id);
|
||||
}
|
||||
} else if (refused?.properties?.includes('uid')) {
|
||||
const card = taken.get(row.uid as string);
|
||||
if (card && holdsSent(row.payload as string, card)) settleCreated(ctx, row, card);
|
||||
else settleRefused(ctx, row, refused, undefined);
|
||||
} else if (refused) {
|
||||
settleRefused(ctx, row, refused, undefined);
|
||||
}
|
||||
}
|
||||
for (const [id, row] of out.update) {
|
||||
const refused = result.notUpdated?.[id] as SetError | undefined;
|
||||
if (refused) settleRefused(ctx, row, refused, current.get(id));
|
||||
else if (updatedIds.includes(id)) settleUpdated(ctx, row, readBack.get(id));
|
||||
}
|
||||
for (const [id, row] of out.destroy) {
|
||||
const refused = result.notDestroyed?.[id] as SetError | undefined;
|
||||
if (refused) settleRefused(ctx, row, refused, current.get(id));
|
||||
else if ((result.destroyed as string[] | undefined)?.includes(id)) dropPending(db, jmap, id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The server said no to the request itself, for a reason that won't go away by itself. */
|
||||
function requestRefusal(error: unknown): SetError | null {
|
||||
if (error instanceof JmapMethodError) return TRANSIENT_ERRORS.has(error.type) ? null : { type: error.type, description: error.message };
|
||||
if (error instanceof TransportError && error.kind === 'http' && error.status !== undefined && error.status >= 400 && error.status < 500) {
|
||||
return { type: 'requestRefused', description: error.message };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One card may be what makes a request fail: the others go one by one, and that one becomes a conflict. */
|
||||
async function sendRows(ctx: SyncContext, jmap: string, rows: readonly PendingRow[], limits: RequestLimits): Promise<void> {
|
||||
try {
|
||||
await exchange(ctx, jmap, rows, limits);
|
||||
} catch (error) {
|
||||
const refusal = requestRefusal(error);
|
||||
if (!refusal) throw error;
|
||||
if (rows.length > 1) {
|
||||
for (const row of rows) {
|
||||
if (ctx.stopped()) return;
|
||||
await sendRows(ctx, jmap, [row], limits);
|
||||
}
|
||||
return;
|
||||
}
|
||||
ctx.db.transaction(() => {
|
||||
const row = pendingFor(ctx.db, jmap, rows[0].id);
|
||||
if (row) settleRefused(ctx, row, refusal, undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends what's queued for one JMAP account. Network failures reach the engine and the rows stay. */
|
||||
export async function flushPending(ctx: SyncContext, jmap: string): Promise<void> {
|
||||
const limits = requestLimits(ctx.session);
|
||||
let afterSeq = 0;
|
||||
let settled = false;
|
||||
try {
|
||||
for (;;) {
|
||||
if (ctx.stopped()) break;
|
||||
const batch = nextBatch(ctx.db, jmap, afterSeq, limits);
|
||||
if (batch.length === 0) break;
|
||||
afterSeq = batch[batch.length - 1].seq;
|
||||
settled = true;
|
||||
await sendRows(ctx, jmap, batch, limits);
|
||||
}
|
||||
} finally {
|
||||
if (settled) ctx.emit({ type: 'changed', account: ctx.account, collections: ['ContactCard'] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/** How a ContactCard becomes rows, and how the search index reads it. */
|
||||
|
||||
import type { SqlValue, StorageAdapter } from '../storage';
|
||||
|
||||
export const CONTACTS_CAPABILITY = 'urn:ietf:params:jmap:contacts';
|
||||
export const CONTACTS_USING = ['urn:ietf:params:jmap:core', CONTACTS_CAPABILITY];
|
||||
|
||||
/** As the client asks for them. Without them Stalwart may leave out shareWith and myRights. */
|
||||
export const ADDRESS_BOOK_PROPERTIES = ['id', 'name', 'description', 'sortOrder', 'isDefault', 'isSubscribed', 'shareWith', 'myRights'];
|
||||
|
||||
export type Card = Record<string, unknown> & { id: string };
|
||||
|
||||
function values(map: unknown): Array<Record<string, unknown>> {
|
||||
if (typeof map !== 'object' || map === null) return [];
|
||||
return Object.values(map).filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null);
|
||||
}
|
||||
|
||||
function strings(items: unknown[]): string[] {
|
||||
return items.filter((item): item is string => typeof item === 'string' && item.length > 0);
|
||||
}
|
||||
|
||||
/** Lower case, no accents: what both the index and the search terms go through. */
|
||||
export function foldText(text: string): string {
|
||||
return text
|
||||
.normalize('NFKD')
|
||||
.replace(/\p{M}+/gu, '')
|
||||
.toLowerCase()
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/æ/g, 'ae')
|
||||
.replace(/œ/g, 'oe')
|
||||
.replace(/[øđł]/g, (c) => ({ ø: 'o', đ: 'd', ł: 'l' })[c] as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* The text to index. A German name is indexed both ways, since people type
|
||||
* "Mueller" for "Müller" as often as "Muller".
|
||||
*/
|
||||
function indexText(text: string): string {
|
||||
const folded = foldText(text);
|
||||
const german = foldText(
|
||||
text.normalize('NFC').replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue'),
|
||||
);
|
||||
return german === folded ? folded : `${folded} ${german}`;
|
||||
}
|
||||
|
||||
export function cardDisplayName(card: Record<string, unknown>): string {
|
||||
const name = card.name as { full?: unknown; components?: unknown } | undefined;
|
||||
if (typeof name?.full === 'string' && name.full) return name.full;
|
||||
const parts = Array.isArray(name?.components) ? strings((name.components as Array<{ value?: unknown }>).map((c) => c?.value)) : [];
|
||||
if (parts.length > 0) return parts.join(' ');
|
||||
return strings(values(card.emails).map((e) => e.address))[0] ?? '';
|
||||
}
|
||||
|
||||
function cardIndex(card: Record<string, unknown>): { name: string; email: string; org: string } {
|
||||
const name = card.name as { full?: unknown; components?: unknown } | undefined;
|
||||
const components = Array.isArray(name?.components) ? (name.components as Array<{ value?: unknown }>).map((c) => c?.value) : [];
|
||||
const nicknames = values(card.nicknames).map((n) => n.name);
|
||||
const emails = values(card.emails).map((e) => e.address);
|
||||
const organizations = values(card.organizations).flatMap((o) => [
|
||||
o.name,
|
||||
...(Array.isArray(o.units) ? (o.units as Array<{ name?: unknown }>).map((u) => u?.name) : []),
|
||||
]);
|
||||
return {
|
||||
name: indexText(strings([name?.full, ...components, ...nicknames]).join(' ')),
|
||||
email: indexText(strings(emails).join(' ')),
|
||||
org: indexText(strings(organizations).join(' ')),
|
||||
};
|
||||
}
|
||||
|
||||
function books(card: Record<string, unknown>): string[] {
|
||||
const ids = card.addressBookIds;
|
||||
if (typeof ids !== 'object' || ids === null) return [];
|
||||
return Object.entries(ids).filter(([, on]) => on).map(([id]) => id);
|
||||
}
|
||||
|
||||
/** Writes one card and its index rows. Without a gen, the row keeps its own. */
|
||||
export function writeCard(db: StorageAdapter, jmap: string, card: Card, gen?: number): void {
|
||||
const existing = db.get<{ rowid: number; gen: number }>('SELECT rowid, gen FROM contact_card WHERE jmap = ? AND id = ?', [jmap, card.id]);
|
||||
const row: SqlValue[] = [
|
||||
typeof card.uid === 'string' ? card.uid : null,
|
||||
foldText(cardDisplayName(card)),
|
||||
JSON.stringify(card),
|
||||
gen ?? Number(existing?.gen ?? 0),
|
||||
];
|
||||
let rowid: number;
|
||||
if (existing) {
|
||||
rowid = Number(existing.rowid);
|
||||
db.run('UPDATE contact_card SET uid = ?, sort_key = ?, json = ?, gen = ? WHERE rowid = ?', [...row, rowid]);
|
||||
dropIndex(db, rowid);
|
||||
} else {
|
||||
rowid = db.run('INSERT INTO contact_card (uid, sort_key, json, gen, jmap, id) VALUES (?, ?, ?, ?, ?, ?)', [...row, jmap, card.id]).lastInsertRowid;
|
||||
}
|
||||
const text = cardIndex(card);
|
||||
for (const table of ['contact_fts_word', 'contact_fts_tri']) {
|
||||
db.run(`INSERT INTO ${table} (rowid, name, email, org) VALUES (?, ?, ?, ?)`, [rowid, text.name, text.email, text.org]);
|
||||
}
|
||||
for (const book of books(card)) db.run('INSERT OR IGNORE INTO contact_card_book (card_rowid, book_id) VALUES (?, ?)', [rowid, book]);
|
||||
}
|
||||
|
||||
function dropIndex(db: StorageAdapter, rowid: number): void {
|
||||
db.run('DELETE FROM contact_fts_word WHERE rowid = ?', [rowid]);
|
||||
db.run('DELETE FROM contact_fts_tri WHERE rowid = ?', [rowid]);
|
||||
db.run('DELETE FROM contact_card_book WHERE card_rowid = ?', [rowid]);
|
||||
}
|
||||
|
||||
export function removeCard(db: StorageAdapter, jmap: string, id: string): void {
|
||||
const existing = db.get<{ rowid: number }>('SELECT rowid FROM contact_card WHERE jmap = ? AND id = ?', [jmap, id]);
|
||||
if (!existing) return;
|
||||
dropIndex(db, Number(existing.rowid));
|
||||
db.run('DELETE FROM contact_card WHERE rowid = ?', [existing.rowid]);
|
||||
}
|
||||
|
||||
export function readCard(db: StorageAdapter, jmap: string, id: string): Card | null {
|
||||
const row = db.get<{ json: string }>('SELECT json FROM contact_card WHERE jmap = ? AND id = ?', [jmap, id]);
|
||||
return row ? JSON.parse(row.json) as Card : null;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Migration } from '../storage';
|
||||
|
||||
export const CONTACT_MIGRATIONS: readonly Migration[] = [
|
||||
{
|
||||
id: 600,
|
||||
name: 'contacts',
|
||||
sql: `
|
||||
CREATE TABLE contact_card (
|
||||
rowid INTEGER PRIMARY KEY,
|
||||
jmap TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
uid TEXT,
|
||||
sort_key TEXT NOT NULL DEFAULT '',
|
||||
-- What the UI sees: the server's card with queued edits on top.
|
||||
json TEXT NOT NULL,
|
||||
gen INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE (jmap, id)
|
||||
);
|
||||
CREATE INDEX contact_card_sort ON contact_card (jmap, sort_key, id);
|
||||
CREATE TABLE contact_card_book (
|
||||
card_rowid INTEGER NOT NULL,
|
||||
book_id TEXT NOT NULL,
|
||||
PRIMARY KEY (card_rowid, book_id)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX contact_card_book_lookup ON contact_card_book (book_id, card_rowid);
|
||||
-- Text goes in through foldText (records.ts), which also takes care of accents.
|
||||
CREATE VIRTUAL TABLE contact_fts_word USING fts5(
|
||||
name, email, org,
|
||||
content = '', contentless_delete = 1,
|
||||
tokenize = 'unicode61 remove_diacritics 0',
|
||||
prefix = '2 3'
|
||||
);
|
||||
CREATE VIRTUAL TABLE contact_fts_tri USING fts5(
|
||||
name, email, org,
|
||||
content = '', contentless_delete = 1,
|
||||
tokenize = 'trigram'
|
||||
);
|
||||
-- One row per card with something to send. Edits to a card merge here.
|
||||
CREATE TABLE contact_pending (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
jmap TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
op TEXT NOT NULL,
|
||||
uid TEXT,
|
||||
-- create: the whole card. update: the merged patch. destroy: nothing.
|
||||
payload TEXT,
|
||||
-- The server's card when the first edit was queued, to see what changed there since.
|
||||
base TEXT,
|
||||
-- A request may have reached the server, so a create looks for its uid first.
|
||||
attempted INTEGER NOT NULL DEFAULT 0,
|
||||
-- The payload that request held: the server may have this, not the newer payload.
|
||||
sent TEXT,
|
||||
queued_at INTEGER NOT NULL,
|
||||
UNIQUE (jmap, id)
|
||||
);
|
||||
-- Stand-in ids the page may still hold after a create went through.
|
||||
CREATE TABLE contact_id_map (
|
||||
jmap TEXT NOT NULL,
|
||||
local_id TEXT NOT NULL,
|
||||
real_id TEXT NOT NULL,
|
||||
PRIMARY KEY (jmap, local_id)
|
||||
) WITHOUT ROWID;
|
||||
CREATE TABLE contact_conflict (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
jmap TEXT NOT NULL,
|
||||
record_id TEXT NOT NULL,
|
||||
-- refused: the server said no. changed: edited there too. gone: deleted there.
|
||||
kind TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
local TEXT NOT NULL,
|
||||
server TEXT,
|
||||
error TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Contact sync for one JMAP account: first what's queued goes out, then the
|
||||
* address books, then the cards. A full pass over the cards captures the
|
||||
* state first and pages, with its cursor in sync_state, so a reload continues
|
||||
* where it stopped. Cards the pass didn't see are gone from the server, so a
|
||||
* page must not skip any: it goes from the last card read (`anchor`), since a
|
||||
* position moves when an earlier card is deleted. Both Stalwarts take that.
|
||||
*/
|
||||
|
||||
import type { SyncContext, Syncer } from '../area';
|
||||
import { runChangesLoop } from '../engine/changes';
|
||||
import { deleteObject, putObject } from '../engine/core-schema';
|
||||
import { requestLimits } from '../engine/limits';
|
||||
import { JmapMethodError, responseFor, TransportError, type MethodCall } from '../engine/transport';
|
||||
import { flushPending, serverDestroyed, storeServerCard } from './pending';
|
||||
import { ADDRESS_BOOK_PROPERTIES, CONTACTS_CAPABILITY, CONTACTS_USING, type Card } from './records';
|
||||
|
||||
const MOST_PAIRS_PER_REQUEST = 4;
|
||||
|
||||
function pageSize(ctx: SyncContext): number {
|
||||
return requestLimits(ctx.session).maxObjectsInGet;
|
||||
}
|
||||
|
||||
// ---- Address books ----
|
||||
|
||||
async function fullBooks(ctx: SyncContext, jmap: string): Promise<void> {
|
||||
const response = await ctx.transport.request([['AddressBook/get', { accountId: jmap, properties: ADDRESS_BOOK_PROPERTIES }, 'b']], CONTACTS_USING);
|
||||
const [, result] = responseFor(response, 'b');
|
||||
ctx.db.transaction(() => {
|
||||
ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'AddressBook'", [jmap]);
|
||||
for (const book of result.list as Array<{ id: string }>) putObject(ctx.db, jmap, 'AddressBook', book.id, book);
|
||||
ctx.setSyncState(jmap, 'AddressBook', { state: String(result.state), phase: null, cursor: null });
|
||||
});
|
||||
ctx.emit({ type: 'changed', account: ctx.account, collections: ['AddressBook'] });
|
||||
}
|
||||
|
||||
async function syncBooks(ctx: SyncContext, jmap: string): Promise<void> {
|
||||
const { state, target } = ctx.syncState(jmap, 'AddressBook');
|
||||
if (!state) return fullBooks(ctx, jmap);
|
||||
const outcome = await runChangesLoop({
|
||||
transport: ctx.transport,
|
||||
type: 'AddressBook',
|
||||
jmap,
|
||||
using: CONTACTS_USING,
|
||||
since: state,
|
||||
target,
|
||||
stopped: ctx.stopped,
|
||||
apply: async (page) => {
|
||||
const ids = [...page.created, ...page.updated];
|
||||
const list: Array<{ id: string }> = [];
|
||||
const size = pageSize(ctx);
|
||||
for (let i = 0; i < ids.length; i += size) {
|
||||
const response = await ctx.transport.request([['AddressBook/get', { accountId: jmap, ids: ids.slice(i, i + size), properties: ADDRESS_BOOK_PROPERTIES }, 'b']], CONTACTS_USING);
|
||||
list.push(...responseFor(response, 'b')[1].list);
|
||||
}
|
||||
ctx.db.transaction(() => {
|
||||
for (const book of list) putObject(ctx.db, jmap, 'AddressBook', book.id, book);
|
||||
for (const id of page.destroyed) deleteObject(ctx.db, jmap, 'AddressBook', id);
|
||||
ctx.setSyncState(jmap, 'AddressBook', { state: page.newState });
|
||||
});
|
||||
if (ids.length + page.destroyed.length > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['AddressBook'] });
|
||||
},
|
||||
});
|
||||
if (outcome.outcome === 'resync') await fullBooks(ctx, jmap);
|
||||
}
|
||||
|
||||
// ---- Cards ----
|
||||
|
||||
interface ListCursor { position: number; anchor: string | null; gen: number; captured: string }
|
||||
|
||||
async function startFullList(ctx: SyncContext, jmap: string): Promise<ListCursor> {
|
||||
const response = await ctx.transport.request([['ContactCard/get', { accountId: jmap, ids: [] }, 's']], CONTACTS_USING);
|
||||
const captured = String(responseFor(response, 's')[1].state);
|
||||
const gen = Number(ctx.db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM contact_card WHERE jmap = ?', [jmap])?.gen ?? 0) + 1;
|
||||
const cursor: ListCursor = { position: 0, anchor: null, gen, captured };
|
||||
// Answers wait for the end of a first pass (phase `first`). A resync has an older copy to give.
|
||||
const phase = ctx.syncState(jmap, 'ContactCard').state ? 'list' : 'first';
|
||||
ctx.db.transaction(() => ctx.setSyncState(jmap, 'ContactCard', { state: null, phase, cursor: { ...cursor } }));
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/** Cards from before this pass that it didn't find, leaving alone creates still on this device. */
|
||||
function sweep(ctx: SyncContext, jmap: string, gen: number): void {
|
||||
const stale = ctx.db.all<{ id: string }>(
|
||||
`SELECT c.id FROM contact_card c WHERE c.jmap = ? AND c.gen < ?
|
||||
AND NOT EXISTS (SELECT 1 FROM contact_pending p WHERE p.jmap = c.jmap AND p.id = c.id AND p.op = 'create')`,
|
||||
[jmap, gen],
|
||||
);
|
||||
for (const row of stale) serverDestroyed(ctx, jmap, String(row.id));
|
||||
}
|
||||
|
||||
async function runFullList(ctx: SyncContext, jmap: string, start: ListCursor): Promise<boolean> {
|
||||
const cursor = { ...start };
|
||||
const limits = requestLimits(ctx.session);
|
||||
const page = limits.maxObjectsInGet;
|
||||
const pairs = Math.max(1, Math.min(MOST_PAIRS_PER_REQUEST, Math.floor(limits.maxCallsInRequest / 2)));
|
||||
for (;;) {
|
||||
if (ctx.stopped()) return false;
|
||||
const calls: MethodCall[] = [];
|
||||
for (let i = 0; i < pairs; i += 1) {
|
||||
const from = cursor.anchor === null ? { position: i * page } : { anchor: cursor.anchor, anchorOffset: 1 + i * page };
|
||||
calls.push(['ContactCard/query', { accountId: jmap, ...from, limit: page, calculateTotal: i === 0 }, `q${i}`]);
|
||||
calls.push(['ContactCard/get', { accountId: jmap, '#ids': { resultOf: `q${i}`, name: 'ContactCard/query', path: '/ids' } }, `g${i}`]);
|
||||
}
|
||||
const response = await ctx.transport.request(calls, CONTACTS_USING);
|
||||
let total: number | null = null;
|
||||
let got = 0;
|
||||
let reached = 0;
|
||||
let short = false;
|
||||
let last: string | null = null;
|
||||
const cards: Card[] = [];
|
||||
try {
|
||||
for (let i = 0; i < pairs && !short; i += 1) {
|
||||
const [, query] = responseFor(response, `q${i}`);
|
||||
const ids = query.ids as string[];
|
||||
if (i === 0 && typeof query.total === 'number') total = query.total;
|
||||
cards.push(...responseFor(response, `g${i}`)[1].list as Card[]);
|
||||
got += ids.length;
|
||||
if (ids.length > 0) last = ids[ids.length - 1];
|
||||
reached = Number(query.position ?? 0) + ids.length;
|
||||
short = ids.length < page;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof JmapMethodError && error.type === 'anchorNotFound')) throw error;
|
||||
// The card it went from is gone. Going through again costs requests, not cards.
|
||||
cursor.anchor = null;
|
||||
cursor.position = 0;
|
||||
ctx.db.transaction(() => ctx.setSyncState(jmap, 'ContactCard', { cursor: { ...cursor } }));
|
||||
continue;
|
||||
}
|
||||
if (last !== null) cursor.anchor = last;
|
||||
cursor.position = reached;
|
||||
// The server may clamp a page, so a short one only ends this if there's no total to go by.
|
||||
const done = got === 0 || (total === null ? short : reached >= total);
|
||||
ctx.db.transaction(() => {
|
||||
for (const card of cards) storeServerCard(ctx.db, jmap, card, cursor.gen);
|
||||
if (done) {
|
||||
sweep(ctx, jmap, cursor.gen);
|
||||
ctx.setSyncState(jmap, 'ContactCard', { state: cursor.captured, phase: null, cursor: null });
|
||||
} else {
|
||||
ctx.setSyncState(jmap, 'ContactCard', { cursor: { ...cursor } });
|
||||
}
|
||||
});
|
||||
ctx.emit({ type: 'progress', account: ctx.account, area: 'contacts', phase: 'list', done: cursor.position, total });
|
||||
if (done) {
|
||||
ctx.emit({ type: 'changed', account: ctx.account, collections: ['ContactCard'] });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDelta(ctx: SyncContext, jmap: string, since: string, target: string | null): Promise<'done' | 'resync' | 'stopped'> {
|
||||
const outcome = await runChangesLoop({
|
||||
transport: ctx.transport,
|
||||
type: 'ContactCard',
|
||||
jmap,
|
||||
using: CONTACTS_USING,
|
||||
since,
|
||||
target,
|
||||
stopped: ctx.stopped,
|
||||
apply: async (page) => {
|
||||
const ids = [...page.created, ...page.updated];
|
||||
const fetched: Card[] = [];
|
||||
const size = pageSize(ctx);
|
||||
for (let i = 0; i < ids.length; i += size) {
|
||||
const response = await ctx.transport.request([['ContactCard/get', { accountId: jmap, ids: ids.slice(i, i + size) }, 'g']], CONTACTS_USING);
|
||||
fetched.push(...responseFor(response, 'g')[1].list as Card[]);
|
||||
}
|
||||
ctx.db.transaction(() => {
|
||||
const gen = Number(ctx.db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM contact_card WHERE jmap = ?', [jmap])?.gen ?? 0);
|
||||
for (const card of fetched) storeServerCard(ctx.db, jmap, card, gen);
|
||||
for (const id of page.destroyed) serverDestroyed(ctx, jmap, id);
|
||||
ctx.setSyncState(jmap, 'ContactCard', { state: page.newState });
|
||||
});
|
||||
if (ids.length + page.destroyed.length > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['ContactCard'] });
|
||||
},
|
||||
});
|
||||
if (outcome.outcome === 'resync') return 'resync';
|
||||
return outcome.outcome === 'stopped' ? 'stopped' : 'done';
|
||||
}
|
||||
|
||||
async function syncCards(ctx: SyncContext, jmap: string): Promise<void> {
|
||||
const row = ctx.syncState(jmap, 'ContactCard');
|
||||
let cursor = row.phase === 'list' || row.phase === 'first' ? row.cursor as unknown as ListCursor : null;
|
||||
if (!cursor && !row.state) cursor = await startFullList(ctx, jmap);
|
||||
if (!cursor && row.state) {
|
||||
const outcome = await syncDelta(ctx, jmap, row.state, row.target);
|
||||
if (outcome === 'stopped') return;
|
||||
if (outcome === 'resync') cursor = await startFullList(ctx, jmap);
|
||||
}
|
||||
if (cursor) {
|
||||
if (!(await runFullList(ctx, jmap, cursor))) return;
|
||||
// Catch what changed while paging.
|
||||
const after = ctx.syncState(jmap, 'ContactCard');
|
||||
if (after.state) await syncDelta(ctx, jmap, after.state, after.target);
|
||||
}
|
||||
}
|
||||
|
||||
export const contactsSyncer: Syncer = {
|
||||
name: 'contacts',
|
||||
capability: CONTACTS_CAPABILITY,
|
||||
async run(ctx, jmap) {
|
||||
try {
|
||||
await flushPending(ctx, jmap);
|
||||
} catch (error) {
|
||||
// No network, no credentials or a busy server is the engine's to handle.
|
||||
// Anything else leaves the edits queued and mustn't stop contacts syncing.
|
||||
if (error instanceof TransportError && error.kind !== 'http') throw error;
|
||||
ctx.emit({ type: 'error', account: ctx.account, area: 'contacts', message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
if (ctx.stopped()) return;
|
||||
await syncBooks(ctx, jmap);
|
||||
if (ctx.stopped()) return;
|
||||
await syncCards(ctx, jmap);
|
||||
},
|
||||
};
|
||||
@@ -4,7 +4,7 @@
|
||||
* the UI. Nothing here touches the DOM, so tests run it on node:sqlite.
|
||||
*/
|
||||
|
||||
import { MethodError, STORAGE_ERROR_AREA, type AreaCall, type MethodHandler, type RecordOverlay, type StoreArea, type StoreContext, type StoreEvent, type StoreScope, type SyncContext, type SyncStateRow, type Syncer } from '../area';
|
||||
import { DEFAULT_SCOPE, MethodError, STORAGE_ERROR_AREA, type AreaCall, type MethodHandler, type RecordOverlay, type StoreArea, type StoreContext, type StoreEvent, type StoreScope, type StoreUsage, type SyncContext, type SyncStateRow, type Syncer } from '../area';
|
||||
import { isStorageFailure, migrate, type StorageAdapter } from '../storage';
|
||||
import { answerRequest, buildMethodTable } from './responder';
|
||||
import { TransportError, type JmapResponse, type JmapTransport, type MethodCall, type StoreSession } from './transport';
|
||||
@@ -16,7 +16,8 @@ export interface AccountEngineOptions {
|
||||
session: StoreSession;
|
||||
transport: JmapTransport;
|
||||
areas: readonly StoreArea[];
|
||||
scope: StoreScope;
|
||||
/** Left out when the page can't tell yet (the policy hasn't arrived): the last one stays. */
|
||||
scope?: StoreScope;
|
||||
invertedKeywordSort?: boolean;
|
||||
emit?: (event: StoreEvent) => void;
|
||||
now?: () => number;
|
||||
@@ -29,6 +30,13 @@ const RATE_BACKOFF_MS = 2_000;
|
||||
/** After a failed write: the first wait, and the longest. */
|
||||
const STORAGE_BACKOFF_MS = 60_000;
|
||||
const STORAGE_BACKOFF_MAX_MS = 60 * 60_000;
|
||||
/** The window moves on without anything changing on the server. */
|
||||
const SCOPE_TIDY_MS = 24 * 60 * 60_000;
|
||||
const SCOPE_KEY = 'scope';
|
||||
const SCOPE_APPLIED_KEY = 'scope_applied';
|
||||
/** Set while one is being applied: part of it may be done, so that's what the next one starts from. */
|
||||
const SCOPE_APPLYING_KEY = 'scope_applying';
|
||||
const SCOPE_TIDIED_KEY = 'scope_tidied_at';
|
||||
|
||||
export class AccountEngine {
|
||||
readonly account: string;
|
||||
@@ -52,12 +60,13 @@ export class AccountEngine {
|
||||
/** A write failed (full disk, quota): no sync before this time, however much the server pushes. */
|
||||
private storageBlockedUntil = 0;
|
||||
private storageFailures = 0;
|
||||
/** The scope changed: a pass under way stops, and what's kept is brought in line first. */
|
||||
private scopeChanged = false;
|
||||
|
||||
constructor(options: AccountEngineOptions) {
|
||||
this.account = options.account;
|
||||
this.db = options.db;
|
||||
this.session = options.session;
|
||||
this.scope = options.scope;
|
||||
this.transport = options.transport;
|
||||
this.areas = options.areas;
|
||||
const syncers = options.areas.flatMap((area) => area.syncers);
|
||||
@@ -73,15 +82,19 @@ export class AccountEngine {
|
||||
this.emitter = options.emit ?? (() => {});
|
||||
this.now = options.now ?? Date.now;
|
||||
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
this.calls.set('store.usage', () => this.usage());
|
||||
migrate(this.db, [...CORE_MIGRATIONS, ...options.areas.flatMap((area) => area.migrations)]);
|
||||
metaSet(this.db, 'session', JSON.stringify(options.session));
|
||||
const storedScope = metaGet(this.db, SCOPE_KEY);
|
||||
this.scope = options.scope ?? (storedScope ? JSON.parse(storedScope) as StoreScope : DEFAULT_SCOPE);
|
||||
metaSet(this.db, SCOPE_KEY, JSON.stringify(this.scope));
|
||||
// An offline start has no way to probe the server, so the last answer stays.
|
||||
this.invertedKeywordSort = options.invertedKeywordSort ?? metaGet(this.db, 'inverted_keyword_sort') === '1';
|
||||
if (options.invertedKeywordSort !== undefined) metaSet(this.db, 'inverted_keyword_sort', options.invertedKeywordSort ? '1' : '0');
|
||||
this.applyScope();
|
||||
this.runScopeHooks();
|
||||
}
|
||||
|
||||
private applyScope(): void {
|
||||
private runScopeHooks(): void {
|
||||
const ctx = this.storeContext();
|
||||
for (const area of this.areas) area.scopeChanged?.(ctx);
|
||||
}
|
||||
@@ -98,12 +111,65 @@ export class AccountEngine {
|
||||
metaSet(this.db, 'session', JSON.stringify(session));
|
||||
}
|
||||
|
||||
setScope(scope: StoreScope): void {
|
||||
/** True when this is a change. The caller asks for a sync, which applies it, also offline. */
|
||||
setScope(scope: StoreScope): boolean {
|
||||
const json = JSON.stringify(scope);
|
||||
if (json === JSON.stringify(this.scope)) return false;
|
||||
this.scope = scope;
|
||||
this.scopeChanged = true;
|
||||
// The user may just have made room by keeping less.
|
||||
this.storageBlockedUntil = 0;
|
||||
metaSet(this.db, 'scope', JSON.stringify(scope));
|
||||
this.applyScope();
|
||||
metaSet(this.db, SCOPE_KEY, json);
|
||||
this.runScopeHooks();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** False when something more urgent than a sync came up: the disk is full. */
|
||||
private async applyScope(): Promise<boolean> {
|
||||
const wanted = JSON.stringify(this.scope);
|
||||
const applied = metaGet(this.db, SCOPE_APPLYING_KEY) ?? metaGet(this.db, SCOPE_APPLIED_KEY);
|
||||
const tidied = Number(metaGet(this.db, SCOPE_TIDIED_KEY) ?? 0);
|
||||
this.scopeChanged = false;
|
||||
if (applied === wanted && metaGet(this.db, SCOPE_APPLYING_KEY) === null && this.now() - tidied < SCOPE_TIDY_MS) return true;
|
||||
const ctx: SyncContext = { ...this.syncContext(), stopped: () => this.closed || this.scopeChanged };
|
||||
const change = { previous: applied ? JSON.parse(applied) as StoreScope : null, next: this.scope };
|
||||
metaSet(this.db, SCOPE_APPLYING_KEY, wanted);
|
||||
try {
|
||||
for (const area of this.areas) await area.applyScope?.(ctx, change);
|
||||
} catch (error) {
|
||||
if (isStorageFailure(error)) {
|
||||
this.noteStorageFailure(error);
|
||||
return false;
|
||||
}
|
||||
// What's kept stays as it is, and this runs again at the next pass.
|
||||
this.emitter({ type: 'error', account: this.account, area: 'scope', message: error instanceof Error ? error.message : String(error) });
|
||||
return true;
|
||||
}
|
||||
if (ctx.stopped()) return !this.closed;
|
||||
this.db.transaction(() => {
|
||||
metaSet(this.db, SCOPE_APPLIED_KEY, wanted);
|
||||
metaSet(this.db, SCOPE_APPLYING_KEY, null);
|
||||
metaSet(this.db, SCOPE_TIDIED_KEY, String(this.now()));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private keptByOthers(name: string): number {
|
||||
return this.areas.reduce((sum, area) => sum + (area.name === name ? 0 : area.keptBytes?.({ db: this.db }) ?? 0), 0);
|
||||
}
|
||||
|
||||
/** Free pages stay in the file for the next writes, so they don't count as used. */
|
||||
private usage(): StoreUsage {
|
||||
const pages = Number(this.db.get<{ page_count: number }>('PRAGMA page_count')?.page_count ?? 0);
|
||||
const free = Number(this.db.get<{ freelist_count: number }>('PRAGMA freelist_count')?.freelist_count ?? 0);
|
||||
const size = Number(this.db.get<{ page_size: number }>('PRAGMA page_size')?.page_size ?? 0);
|
||||
const bytes = (pages - free) * size;
|
||||
const parts = this.areas.flatMap((area) => area.usage?.({ db: this.db }) ?? []);
|
||||
const counted = parts.reduce((sum, part) => sum + part.bytes, 0);
|
||||
return {
|
||||
bytes,
|
||||
parts: parts.map((part) => (part.part === 'mail' ? { ...part, bytes: Math.max(0, bytes - counted) } : part)),
|
||||
};
|
||||
}
|
||||
|
||||
setInvertedKeywordSort(inverted: boolean): void {
|
||||
@@ -171,8 +237,9 @@ export class AccountEngine {
|
||||
private async runLoop(): Promise<void> {
|
||||
do {
|
||||
this.again = false;
|
||||
await this.runOnce();
|
||||
} while (this.again && !this.closed && this.online && this.now() >= this.storageBlockedUntil);
|
||||
if (await this.applyScope()) await this.runOnce();
|
||||
// A changed scope is applied without the network too.
|
||||
} while (!this.closed && this.now() >= this.storageBlockedUntil && (this.scopeChanged || (this.again && this.online)));
|
||||
}
|
||||
|
||||
private async runOnce(): Promise<void> {
|
||||
@@ -183,10 +250,11 @@ export class AccountEngine {
|
||||
for (const jmap of jmapAccounts) {
|
||||
const capabilities = this.session.accounts[jmap].accountCapabilities ?? this.session.capabilities;
|
||||
if (!(syncer.capability in capabilities)) continue;
|
||||
if (this.closed || !this.online) return;
|
||||
if (this.closed || !this.online || this.scopeChanged) return;
|
||||
if (!(await this.runSyncer(ctx, syncer, jmap))) return;
|
||||
}
|
||||
}
|
||||
if (this.scopeChanged) return;
|
||||
this.storageFailures = 0;
|
||||
if (!this.closed && this.online) this.emitter({ type: 'synced', account: this.account, at: this.now() });
|
||||
}
|
||||
@@ -247,12 +315,15 @@ export class AccountEngine {
|
||||
transport: this.transport,
|
||||
now: this.now,
|
||||
emit: this.emitter,
|
||||
stopped: () => this.closed || !this.online,
|
||||
stopped: () => this.closed || !this.online || this.scopeChanged,
|
||||
requestSync: () => this.requestSync(),
|
||||
syncWanted: () => this.again,
|
||||
pause: this.sleep,
|
||||
syncState: (jmap, type) => this.syncState(jmap, type),
|
||||
setSyncState: (jmap, type, row) => this.setSyncState(jmap, type, row),
|
||||
overlay: (jmap, type, records) => this.overlay(jmap, type, records),
|
||||
keptByOthers: (area) => this.keptByOthers(area),
|
||||
sparedRecords: (jmap, type) => this.areas.flatMap((area) => area.spares?.({ db: this.db }, jmap, type) ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -265,6 +336,9 @@ export class AccountEngine {
|
||||
now: this.now,
|
||||
emit: this.emitter,
|
||||
overlay: (jmap, type, records) => this.overlay(jmap, type, records),
|
||||
keptByOthers: (area) => this.keptByOthers(area),
|
||||
sparedRecords: (jmap, type) => this.areas.flatMap((area) => area.spares?.({ db: this.db }, jmap, type) ?? []),
|
||||
requestSync: () => this.requestSync(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/** What one request may hold: the session's limits, never past the ones the engine was built for. */
|
||||
|
||||
import type { StoreSession } from './transport';
|
||||
|
||||
export interface RequestLimits {
|
||||
maxObjectsInGet: number;
|
||||
maxObjectsInSet: number;
|
||||
maxCallsInRequest: number;
|
||||
maxSizeRequest: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: RequestLimits = {
|
||||
maxObjectsInGet: 500,
|
||||
maxObjectsInSet: 500,
|
||||
maxCallsInRequest: 16,
|
||||
maxSizeRequest: 10 * 1024 * 1024,
|
||||
};
|
||||
|
||||
export function requestLimits(session: StoreSession): RequestLimits {
|
||||
const core = (session.capabilities['urn:ietf:params:jmap:core'] ?? {}) as Partial<RequestLimits>;
|
||||
const limit = (key: keyof RequestLimits) => {
|
||||
const value = core[key];
|
||||
return typeof value === 'number' && value > 0 ? Math.min(value, DEFAULTS[key]) : DEFAULTS[key];
|
||||
};
|
||||
return {
|
||||
maxObjectsInGet: limit('maxObjectsInGet'),
|
||||
maxObjectsInSet: limit('maxObjectsInSet'),
|
||||
maxCallsInRequest: limit('maxCallsInRequest'),
|
||||
maxSizeRequest: limit('maxSizeRequest'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { StoreArea } from '../area';
|
||||
import { applyFilesScope, fileBlob, filesUsage, keptStatus, noteOpened, pinFile, readFile, unpinFile, type NodeRef } from './keep';
|
||||
import { FILE_METHODS } from './methods';
|
||||
import { FILE_MIGRATIONS } from './schema';
|
||||
import { filesSyncer } from './sync';
|
||||
|
||||
export { fileBlob, FILE_CACHE_CAP_BYTES } from './keep';
|
||||
|
||||
export const filesArea: StoreArea = {
|
||||
name: 'files',
|
||||
migrations: FILE_MIGRATIONS,
|
||||
methods: FILE_METHODS,
|
||||
syncers: [filesSyncer],
|
||||
applyScope: applyFilesScope,
|
||||
usage: filesUsage,
|
||||
keptBytes: (ctx) => filesUsage(ctx)[0].bytes,
|
||||
calls: {
|
||||
/** Keep this file's content on the device until it's unpinned. */
|
||||
'files.pin': (ctx, params: NodeRef) => pinFile(ctx, params),
|
||||
'files.unpin': (ctx, params: NodeRef) => unpinFile(ctx, params),
|
||||
/** The page opened this file: keep its content for a while. */
|
||||
'files.noteOpened': (ctx, params: NodeRef) => noteOpened(ctx, params),
|
||||
/** A kept file's content and name, or null. */
|
||||
'files.read': (ctx, params: NodeRef) => readFile(ctx, params),
|
||||
/** A kept content by blob id, or null. */
|
||||
'files.blob': (ctx, params: { jmap: string; blobId: string }) => fileBlob(ctx, params.jmap, params.blobId),
|
||||
/** Pinned and recently opened files, for a settings page. */
|
||||
'files.kept': (ctx) => keptStatus(ctx),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* File contents kept on the device: pinned files, and recently opened ones
|
||||
* within a cap. Metadata for every file is in file_node regardless.
|
||||
*
|
||||
* What gets kept:
|
||||
* - A pinned file stays until it's unpinned or removed on the server. The cap
|
||||
* doesn't push it out and the user's own size limit doesn't apply. The
|
||||
* organisation's limit on one file (scope.orgMaxFileBytes) and the account's
|
||||
* total (scope.maxTotalBytes, which a policy can lower) do.
|
||||
* - A recently opened file is kept if it's within scope.maxAttachmentBytes, and
|
||||
* pushed out oldest first when the cap is reached. Its opened_at goes with
|
||||
* it, so it isn't fetched straight back.
|
||||
* - With scope.attachments off, nothing is kept.
|
||||
*/
|
||||
|
||||
import { TransportError } from '../engine/transport';
|
||||
import { MethodError, type ScopeChange, type StoreContext, type StoreUsagePart, type SyncContext } from '../area';
|
||||
import type { StorageAdapter } from '../storage';
|
||||
import { nodeMediaType, type WireNode } from './records';
|
||||
|
||||
export const FILE_CACHE_CAP_BYTES = 500 * 1024 * 1024;
|
||||
const CHUNK_BYTES = 1024 * 1024;
|
||||
|
||||
export interface KeptFile { data: Uint8Array; type: string; size: number }
|
||||
|
||||
export interface NodeRef { jmap: string; id: string }
|
||||
|
||||
interface NodeRow {
|
||||
jmap: string;
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
blob_id: string | null;
|
||||
size: number | null;
|
||||
json: string;
|
||||
pinned: number;
|
||||
opened_at: number | null;
|
||||
keep_error: string | null;
|
||||
}
|
||||
|
||||
interface BlobRow {
|
||||
jmap: string;
|
||||
blob_id: string;
|
||||
size: number;
|
||||
kept_at: number;
|
||||
pinned: number;
|
||||
opened_at: number | null;
|
||||
}
|
||||
|
||||
function capBytes(ctx: Pick<StoreContext, 'scope'>): number {
|
||||
return Math.min(FILE_CACHE_CAP_BYTES, ctx.scope.maxTotalBytes);
|
||||
}
|
||||
|
||||
/** A kept file's content, for the client's blob lookup. */
|
||||
export function fileBlob(ctx: Pick<StoreContext, 'db'>, jmap: string, blobId: string): KeptFile | null {
|
||||
const row = ctx.db.get<{ type: string; size: number }>('SELECT type, size FROM file_blob WHERE jmap = ? AND blob_id = ?', [jmap, blobId]);
|
||||
if (!row) return null;
|
||||
const data = new Uint8Array(Number(row.size));
|
||||
let at = 0;
|
||||
for (const chunk of ctx.db.all<{ data: Uint8Array }>('SELECT data FROM file_blob_chunk WHERE jmap = ? AND blob_id = ? ORDER BY seq', [jmap, blobId])) {
|
||||
data.set(chunk.data, at);
|
||||
at += chunk.data.length;
|
||||
}
|
||||
return { data, type: row.type, size: Number(row.size) };
|
||||
}
|
||||
|
||||
function hasBlob(db: StorageAdapter, jmap: string, blobId: string): boolean {
|
||||
return Boolean(db.get('SELECT 1 AS x FROM file_blob WHERE jmap = ? AND blob_id = ?', [jmap, blobId]));
|
||||
}
|
||||
|
||||
function dropBlob(db: StorageAdapter, jmap: string, blobId: string): void {
|
||||
db.run('DELETE FROM file_blob_chunk WHERE jmap = ? AND blob_id = ?', [jmap, blobId]);
|
||||
db.run('DELETE FROM file_blob WHERE jmap = ? AND blob_id = ?', [jmap, blobId]);
|
||||
}
|
||||
|
||||
function storeBlob(ctx: StoreContext, jmap: string, blobId: string, type: string, data: Uint8Array): void {
|
||||
dropBlob(ctx.db, jmap, blobId);
|
||||
ctx.db.run('INSERT INTO file_blob (jmap, blob_id, type, size, kept_at) VALUES (?, ?, ?, ?, ?)', [jmap, blobId, type, data.length, ctx.now()]);
|
||||
for (let seq = 0, at = 0; at < data.length; seq += 1, at += CHUNK_BYTES) {
|
||||
ctx.db.run('INSERT INTO file_blob_chunk (jmap, blob_id, seq, data) VALUES (?, ?, ?, ?)', [jmap, blobId, seq, data.subarray(at, at + CHUNK_BYTES)]);
|
||||
}
|
||||
}
|
||||
|
||||
/** All kept contents in the store, with what the nodes pointing at each say about it. */
|
||||
function keptBlobs(db: StorageAdapter): BlobRow[] {
|
||||
return db.all<BlobRow>(
|
||||
`SELECT b.jmap, b.blob_id, b.size, b.kept_at, COALESCE(MAX(n.pinned), 0) AS pinned, MAX(n.opened_at) AS opened_at
|
||||
FROM file_blob b LEFT JOIN file_node n ON n.jmap = b.jmap AND n.blob_id = b.blob_id
|
||||
GROUP BY b.jmap, b.blob_id`,
|
||||
);
|
||||
}
|
||||
|
||||
function ageOf(blob: BlobRow): number {
|
||||
return Number(blob.opened_at ?? blob.kept_at);
|
||||
}
|
||||
|
||||
function pushOut(db: StorageAdapter, blob: BlobRow): void {
|
||||
dropBlob(db, blob.jmap, blob.blob_id);
|
||||
db.run('UPDATE file_node SET opened_at = NULL WHERE jmap = ? AND blob_id = ?', [blob.jmap, blob.blob_id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes out unpinned contents, oldest first, so `size` more fits under the
|
||||
* cap. A recently opened file only pushes out older ones and says false when
|
||||
* that isn't enough. A pinned one always gets in.
|
||||
*/
|
||||
function makeRoom(ctx: StoreContext, size: number, incoming: { pinned: boolean; openedAt: number }): boolean {
|
||||
const cap = capBytes(ctx);
|
||||
const blobs = keptBlobs(ctx.db);
|
||||
let used = blobs.reduce((sum, blob) => sum + Number(blob.size), 0);
|
||||
const candidates = blobs
|
||||
.filter((blob) => !blob.pinned && (incoming.pinned || ageOf(blob) < incoming.openedAt))
|
||||
.sort((a, b) => ageOf(a) - ageOf(b));
|
||||
const chosen: BlobRow[] = [];
|
||||
for (const blob of candidates) {
|
||||
if (used + size <= cap) break;
|
||||
used -= Number(blob.size);
|
||||
chosen.push(blob);
|
||||
}
|
||||
if (used + size > cap && !incoming.pinned) return false;
|
||||
for (const blob of chosen) pushOut(ctx.db, blob);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The organisation's limit on one file, which a pin obeys. The user's own doesn't bind a pin. */
|
||||
function overOrgLimit(ctx: Pick<StoreContext, 'scope'>, size: number): boolean {
|
||||
const cap = ctx.scope.orgMaxFileBytes;
|
||||
return typeof cap === 'number' && size > cap;
|
||||
}
|
||||
|
||||
function pinnedBytes(db: StorageAdapter): number {
|
||||
return keptBlobs(db).filter((blob) => blob.pinned).reduce((sum, blob) => sum + Number(blob.size), 0);
|
||||
}
|
||||
|
||||
function setError(db: StorageAdapter, jmap: string, blobId: string, error: string | null): void {
|
||||
db.run('UPDATE file_node SET keep_error = ? WHERE jmap = ? AND blob_id = ?', [error, jmap, blobId]);
|
||||
}
|
||||
|
||||
/** Contents no node points at any more: the file was removed or has a new version. */
|
||||
export function dropOrphanBlobs(db: StorageAdapter): void {
|
||||
for (const blob of keptBlobs(db)) {
|
||||
const used = db.get('SELECT 1 AS x FROM file_node WHERE jmap = ? AND blob_id = ?', [blob.jmap, blob.blob_id]);
|
||||
if (!used) dropBlob(db, blob.jmap, blob.blob_id);
|
||||
}
|
||||
}
|
||||
|
||||
interface Wanted { blob_id: string; id: string; name: string; size: number | null; json: string; pinned: number; opened_at: number | null }
|
||||
|
||||
function wantedRow(db: StorageAdapter, jmap: string, blobId: string): Wanted | undefined {
|
||||
return db.get<Wanted>(
|
||||
`SELECT blob_id, id, name, size, json, pinned, opened_at FROM file_node
|
||||
WHERE jmap = ? AND blob_id = ? AND (pinned = 1 OR opened_at IS NOT NULL)
|
||||
ORDER BY pinned DESC, opened_at DESC LIMIT 1`,
|
||||
[jmap, blobId],
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetches what should be kept and isn't yet, one file at a time, pinned files first. */
|
||||
export async function fetchKeptFiles(ctx: SyncContext, jmap: string): Promise<void> {
|
||||
const { db } = ctx;
|
||||
if (!ctx.scope.attachments) {
|
||||
db.transaction(() => {
|
||||
for (const blob of keptBlobs(db)) dropBlob(db, blob.jmap, blob.blob_id);
|
||||
db.run("UPDATE file_node SET keep_error = 'notAllowed' WHERE jmap = ? AND (pinned = 1 OR opened_at IS NOT NULL)", [jmap]);
|
||||
});
|
||||
return;
|
||||
}
|
||||
db.transaction(() => {
|
||||
db.run("UPDATE file_node SET keep_error = NULL WHERE jmap = ? AND keep_error = 'notAllowed'", [jmap]);
|
||||
// The limit may have gone down since last time.
|
||||
makeRoom(ctx, 0, { pinned: true, openedAt: 0 });
|
||||
});
|
||||
const todo = db.all<{ blob_id: string }>(
|
||||
`SELECT n.blob_id FROM file_node n
|
||||
WHERE n.jmap = ? AND n.blob_id IS NOT NULL AND (n.pinned = 1 OR n.opened_at IS NOT NULL)
|
||||
AND NOT EXISTS (SELECT 1 FROM file_blob b WHERE b.jmap = n.jmap AND b.blob_id = n.blob_id)
|
||||
GROUP BY n.blob_id ORDER BY MAX(n.pinned) DESC, MAX(n.opened_at) DESC`,
|
||||
[jmap],
|
||||
).map((row) => String(row.blob_id));
|
||||
let done = 0;
|
||||
for (const blobId of todo) {
|
||||
if (ctx.stopped()) return;
|
||||
// The page may have changed its mind during the last download.
|
||||
const want = wantedRow(db, jmap, blobId);
|
||||
if (!want) continue;
|
||||
const known = Number(want.size ?? 0);
|
||||
if (want.pinned && overOrgLimit(ctx, known)) {
|
||||
setError(db, jmap, blobId, 'tooLarge');
|
||||
continue;
|
||||
}
|
||||
if (!want.pinned && known > Math.min(ctx.scope.maxAttachmentBytes, capBytes(ctx))) {
|
||||
setError(db, jmap, blobId, 'tooLarge');
|
||||
continue;
|
||||
}
|
||||
if (want.pinned && pinnedBytes(db) + known > ctx.scope.maxTotalBytes) {
|
||||
setError(db, jmap, blobId, 'overLimit');
|
||||
continue;
|
||||
}
|
||||
let data: Uint8Array;
|
||||
const type = nodeMediaType(JSON.parse(want.json) as WireNode);
|
||||
try {
|
||||
data = await ctx.transport.download(jmap, blobId, type, want.name);
|
||||
} catch (error) {
|
||||
// No network or no credentials reach the engine. One bad file doesn't stop the others.
|
||||
if (error instanceof TransportError && error.kind === 'http') {
|
||||
setError(db, jmap, blobId, 'download');
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
db.transaction(() => {
|
||||
const now = wantedRow(db, jmap, blobId);
|
||||
if (!now) return;
|
||||
if (now.pinned ? overOrgLimit(ctx, data.length) || pinnedBytes(db) + data.length > ctx.scope.maxTotalBytes : data.length > Math.min(ctx.scope.maxAttachmentBytes, capBytes(ctx))) {
|
||||
// The size in the metadata was wrong or missing. With the real one
|
||||
// noted, the next pass sees this without downloading it again.
|
||||
db.run('UPDATE file_node SET size = ? WHERE jmap = ? AND blob_id = ?', [data.length, jmap, blobId]);
|
||||
setError(db, jmap, blobId, !now.pinned || overOrgLimit(ctx, data.length) ? 'tooLarge' : 'overLimit');
|
||||
return;
|
||||
}
|
||||
if (!makeRoom(ctx, data.length, { pinned: Boolean(now.pinned), openedAt: Number(now.opened_at ?? 0) })) {
|
||||
db.run('UPDATE file_node SET opened_at = NULL WHERE jmap = ? AND blob_id = ? AND pinned = 0', [jmap, blobId]);
|
||||
return;
|
||||
}
|
||||
storeBlob(ctx, jmap, blobId, type, data);
|
||||
setError(db, jmap, blobId, null);
|
||||
});
|
||||
done += 1;
|
||||
ctx.emit({ type: 'progress', account: ctx.account, area: 'files', phase: 'contents', done, total: todo.length });
|
||||
}
|
||||
if (done > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['FileContent'] });
|
||||
}
|
||||
|
||||
/**
|
||||
* What the scope no longer allows leaves now, without the network. The pins
|
||||
* stay, so a loosened scope fetches them back at the next pass. A pin ignores
|
||||
* the size limit but not the total: the organisation's cap wins over it.
|
||||
*/
|
||||
export function applyFilesScope(ctx: SyncContext, _change: ScopeChange): void {
|
||||
const { db, scope } = ctx;
|
||||
let changed = false;
|
||||
db.transaction(() => {
|
||||
if (!scope.attachments) {
|
||||
for (const blob of keptBlobs(db)) {
|
||||
dropBlob(db, blob.jmap, blob.blob_id);
|
||||
changed = true;
|
||||
}
|
||||
db.run("UPDATE file_node SET keep_error = 'notAllowed' WHERE pinned = 1 OR opened_at IS NOT NULL");
|
||||
return;
|
||||
}
|
||||
const largest = Math.min(scope.maxAttachmentBytes, capBytes(ctx));
|
||||
for (const blob of keptBlobs(db)) {
|
||||
if (blob.pinned ? !overOrgLimit(ctx, Number(blob.size)) : Number(blob.size) <= largest) continue;
|
||||
if (blob.pinned) {
|
||||
dropBlob(db, blob.jmap, blob.blob_id);
|
||||
setError(db, blob.jmap, blob.blob_id, 'tooLarge');
|
||||
} else {
|
||||
pushOut(db, blob);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
const before = keptBlobs(db).length;
|
||||
makeRoom(ctx, 0, { pinned: true, openedAt: 0 });
|
||||
if (keptBlobs(db).length !== before) changed = true;
|
||||
// Past the total with pins alone: the newest kept go first, as they would have been refused.
|
||||
let pinned = pinnedBytes(db);
|
||||
for (const blob of keptBlobs(db).filter((row) => row.pinned).sort((a, b) => Number(b.kept_at) - Number(a.kept_at))) {
|
||||
if (pinned <= scope.maxTotalBytes) break;
|
||||
pinned -= Number(blob.size);
|
||||
dropBlob(db, blob.jmap, blob.blob_id);
|
||||
setError(db, blob.jmap, blob.blob_id, 'overLimit');
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) ctx.emit({ type: 'changed', account: ctx.account, collections: ['FileContent'] });
|
||||
}
|
||||
|
||||
export function filesUsage(ctx: Pick<StoreContext, 'db'>): StoreUsagePart[] {
|
||||
const row = ctx.db.get<{ bytes: number | null; items: number }>('SELECT SUM(size) AS bytes, COUNT(*) AS items FROM file_blob');
|
||||
return [{ part: 'files', bytes: Number(row?.bytes ?? 0), items: Number(row?.items ?? 0) }];
|
||||
}
|
||||
|
||||
// ---- Calls from the page ----
|
||||
|
||||
function fileRow(db: StorageAdapter, { jmap, id }: NodeRef): NodeRow {
|
||||
const row = db.get<NodeRow>('SELECT jmap, id, name, kind, blob_id, size, json, pinned, opened_at, keep_error FROM file_node WHERE jmap = ? AND id = ?', [jmap, id]);
|
||||
if (!row) throw new MethodError('notFound', `No file ${id}`);
|
||||
if (!row.blob_id || row.kind !== 'file') throw new MethodError('invalidArguments', 'Only files can be kept');
|
||||
return row;
|
||||
}
|
||||
|
||||
export function pinFile(ctx: StoreContext, ref: NodeRef): { kept: boolean } {
|
||||
const row = fileRow(ctx.db, ref);
|
||||
ctx.db.run('UPDATE file_node SET pinned = 1, keep_error = NULL WHERE jmap = ? AND id = ?', [ref.jmap, ref.id]);
|
||||
const kept = hasBlob(ctx.db, ref.jmap, row.blob_id as string);
|
||||
if (!kept) void ctx.requestSync();
|
||||
return { kept };
|
||||
}
|
||||
|
||||
/** Back to being a recently opened file, or let go of it if it never was one. */
|
||||
export function unpinFile(ctx: StoreContext, ref: NodeRef): void {
|
||||
const row = fileRow(ctx.db, ref);
|
||||
ctx.db.transaction(() => {
|
||||
ctx.db.run('UPDATE file_node SET pinned = 0 WHERE jmap = ? AND id = ?', [ref.jmap, ref.id]);
|
||||
if (!wantedRow(ctx.db, ref.jmap, row.blob_id as string)) dropBlob(ctx.db, ref.jmap, row.blob_id as string);
|
||||
});
|
||||
}
|
||||
|
||||
export function noteOpened(ctx: StoreContext, ref: NodeRef): { kept: boolean } {
|
||||
const row = fileRow(ctx.db, ref);
|
||||
ctx.db.run('UPDATE file_node SET opened_at = ?, keep_error = NULL WHERE jmap = ? AND id = ?', [ctx.now(), ref.jmap, ref.id]);
|
||||
const kept = hasBlob(ctx.db, ref.jmap, row.blob_id as string);
|
||||
if (!kept) void ctx.requestSync();
|
||||
return { kept };
|
||||
}
|
||||
|
||||
export function readFile(ctx: StoreContext, ref: NodeRef): (KeptFile & { name: string }) | null {
|
||||
const row = fileRow(ctx.db, ref);
|
||||
const kept = fileBlob(ctx, ref.jmap, row.blob_id as string);
|
||||
return kept ? { ...kept, name: row.name } : null;
|
||||
}
|
||||
|
||||
export interface KeptFileStatus {
|
||||
jmap: string;
|
||||
id: string;
|
||||
name: string;
|
||||
size: number | null;
|
||||
pinned: boolean;
|
||||
openedAt: number | null;
|
||||
kept: boolean;
|
||||
/** Why it isn't kept: tooLarge, overLimit, notAllowed or download. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Pinned and recently opened files, and how much room their contents take. */
|
||||
export function keptStatus(ctx: StoreContext): { files: KeptFileStatus[]; bytes: number; capBytes: number } {
|
||||
const rows = ctx.db.all<NodeRow>(
|
||||
`SELECT jmap, id, name, kind, blob_id, size, json, pinned, opened_at, keep_error FROM file_node
|
||||
WHERE pinned = 1 OR opened_at IS NOT NULL ORDER BY pinned DESC, opened_at DESC, name`,
|
||||
);
|
||||
return {
|
||||
files: rows.map((row) => ({
|
||||
jmap: row.jmap,
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
size: row.size === null ? null : Number(row.size),
|
||||
pinned: Boolean(row.pinned),
|
||||
openedAt: row.opened_at === null ? null : Number(row.opened_at),
|
||||
kept: row.blob_id !== null && hasBlob(ctx.db, row.jmap, row.blob_id),
|
||||
error: row.keep_error,
|
||||
})),
|
||||
bytes: keptBlobs(ctx.db).reduce((sum, blob) => sum + Number(blob.size), 0),
|
||||
capBytes: capBytes(ctx),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* FileNode reads answered from the store, so listings work offline. Nothing
|
||||
* here answers FileNode/set: the responder gives it a per-call
|
||||
* serverUnavailable, since the design has no offline file writes.
|
||||
*/
|
||||
|
||||
import { MethodError, type MethodContext, type MethodHandler, type MethodResult } from '../area';
|
||||
import type { SqlValue } from '../storage';
|
||||
import { FILES_CAPABILITY } from './records';
|
||||
|
||||
function jmapOf(ctx: MethodContext, args: Record<string, unknown>): string {
|
||||
return (args.accountId as string | undefined) ?? ctx.session.primaryAccounts[FILES_CAPABILITY] ?? ctx.defaultJmap;
|
||||
}
|
||||
|
||||
/** The last state from the server. Throws when the list was never read, so an empty answer always means empty. */
|
||||
function syncedState(ctx: MethodContext, jmap: string): string | null {
|
||||
const row = ctx.db.get<{ state: string | null; phase: string | null }>("SELECT state, phase FROM sync_state WHERE jmap = ? AND type = 'FileNode'", [jmap]);
|
||||
if (!row || (!row.state && !row.phase)) throw new MethodError('serverUnavailable', 'Files not kept yet');
|
||||
return row.state;
|
||||
}
|
||||
|
||||
function pick(node: Record<string, unknown>, properties: unknown): Record<string, unknown> {
|
||||
if (!Array.isArray(properties)) return node;
|
||||
const out: Record<string, unknown> = { id: node.id };
|
||||
for (const property of properties as string[]) if (property in node) out[property] = node[property];
|
||||
return out;
|
||||
}
|
||||
|
||||
const SORTS: Record<string, string> = {
|
||||
name: 'name COLLATE NOCASE',
|
||||
size: 'size',
|
||||
created: "json_extract(json, '$.created')",
|
||||
modified: "json_extract(json, '$.modified')",
|
||||
};
|
||||
|
||||
function orderSql(sort: unknown): string {
|
||||
const parts: string[] = [];
|
||||
for (const level of (Array.isArray(sort) ? sort : []) as Array<{ property?: string; isAscending?: boolean }>) {
|
||||
const column = SORTS[String(level.property)];
|
||||
if (!column) throw new MethodError('unsupportedSort', `${String(level.property)} isn't available offline`);
|
||||
parts.push(`${column} ${level.isAscending === false ? 'DESC' : 'ASC'}`);
|
||||
}
|
||||
parts.push('name COLLATE NOCASE ASC', 'id ASC');
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
const fileNodeGet: MethodHandler = (ctx, args) => {
|
||||
const jmap = jmapOf(ctx, args);
|
||||
const state = syncedState(ctx, jmap);
|
||||
const list: Array<Record<string, unknown>> = [];
|
||||
const notFound: string[] = [];
|
||||
if (Array.isArray(args.ids)) {
|
||||
for (const id of args.ids as string[]) {
|
||||
const row = ctx.db.get<{ json: string }>('SELECT json FROM file_node WHERE jmap = ? AND id = ?', [jmap, id]);
|
||||
if (row) list.push(pick(JSON.parse(row.json) as Record<string, unknown>, args.properties));
|
||||
else notFound.push(id);
|
||||
}
|
||||
} else {
|
||||
// The whole tree at once. The client reads a long list as possibly cut,
|
||||
// pages FileNode/query to check, and finds nothing missing.
|
||||
for (const row of ctx.db.all<{ json: string }>('SELECT json FROM file_node WHERE jmap = ?', [jmap])) {
|
||||
list.push(pick(JSON.parse(row.json) as Record<string, unknown>, args.properties));
|
||||
}
|
||||
}
|
||||
return { accountId: jmap, ...(state ? { state } : {}), list, notFound };
|
||||
};
|
||||
|
||||
const fileNodeQuery: MethodHandler = (ctx, args) => {
|
||||
const jmap = jmapOf(ctx, args);
|
||||
syncedState(ctx, jmap);
|
||||
if (args.anchor !== undefined) throw new MethodError('unsupportedFilter', 'anchor needs the server');
|
||||
const filter = (args.filter ?? {}) as Record<string, unknown>;
|
||||
const conditions = ['jmap = ?'];
|
||||
const params: SqlValue[] = [jmap];
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (key !== 'parentId') throw new MethodError('unsupportedFilter', `${key} isn't available offline`);
|
||||
if (value === null) {
|
||||
conditions.push('parent_id IS NULL');
|
||||
} else {
|
||||
conditions.push('parent_id = ?');
|
||||
params.push(String(value));
|
||||
}
|
||||
}
|
||||
const where = conditions.join(' AND ');
|
||||
const position = Math.max(0, Number(args.position ?? 0));
|
||||
const limit = typeof args.limit === 'number' && args.limit > 0 ? args.limit : -1;
|
||||
const ids = ctx.db.all<{ id: string }>(
|
||||
`SELECT id FROM file_node WHERE ${where} ORDER BY ${orderSql(args.sort)} LIMIT ? OFFSET ?`,
|
||||
[...params, limit, position],
|
||||
).map((row) => String(row.id));
|
||||
const result: MethodResult = {
|
||||
accountId: jmap,
|
||||
// Local answers aren't a position in the server's log, so this is a token only.
|
||||
queryState: 'local',
|
||||
canCalculateChanges: false,
|
||||
position,
|
||||
ids,
|
||||
};
|
||||
if (args.calculateTotal) {
|
||||
result.total = Number(ctx.db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM file_node WHERE ${where}`, params)?.n ?? 0);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const FILE_METHODS: Readonly<Record<string, MethodHandler>> = {
|
||||
'FileNode/get': fileNodeGet,
|
||||
'FileNode/query': fileNodeQuery,
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/** How a FileNode becomes a row. The server's own copy stays in `json`, in whichever draft it speaks. */
|
||||
|
||||
import { fileNodeKind, fromDraftFileNode } from '@/lib/jmap/filenode-kind';
|
||||
import { decodeFileNodeName } from '@/lib/jmap/filenode-name';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
import type { StorageAdapter } from '../storage';
|
||||
|
||||
export const FILES_CAPABILITY = 'urn:ietf:params:jmap:filenode';
|
||||
export const FILES_USING = ['urn:ietf:params:jmap:core', FILES_CAPABILITY];
|
||||
|
||||
/** The client's list (lib/jmap/client.ts). Stalwart leaves out shareWith and myRights unless asked. */
|
||||
export const FILE_NODE_PROPERTIES = [
|
||||
'id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'modified',
|
||||
'nodeType', '@type', 'mediaType', 'shareWith', 'myRights',
|
||||
];
|
||||
|
||||
export type WireNode = Record<string, unknown> & { id: string };
|
||||
|
||||
/** The node's media type, whichever draft named it. */
|
||||
export function nodeMediaType(node: WireNode): string {
|
||||
const { type } = fromDraftFileNode(node as unknown as Partial<FileNode> & { '@type'?: unknown; mediaType?: unknown });
|
||||
return typeof type === 'string' && type ? type : 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function writeNode(db: StorageAdapter, jmap: string, node: WireNode, gen: number): void {
|
||||
const normal = fromDraftFileNode(node as unknown as FileNode & { '@type'?: unknown; mediaType?: unknown });
|
||||
db.run(
|
||||
`INSERT INTO file_node (jmap, id, parent_id, name, kind, blob_id, size, json, gen) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (jmap, id) DO UPDATE SET parent_id = excluded.parent_id, name = excluded.name, kind = excluded.kind,
|
||||
size = excluded.size, json = excluded.json, gen = excluded.gen,
|
||||
keep_error = CASE WHEN blob_id IS excluded.blob_id THEN keep_error END,
|
||||
blob_id = excluded.blob_id`,
|
||||
[
|
||||
jmap,
|
||||
node.id,
|
||||
typeof node.parentId === 'string' ? node.parentId : null,
|
||||
// The client decodes names itself from `json`. This column is for the store's own lists.
|
||||
typeof node.name === 'string' ? decodeFileNodeName(node.name) : '',
|
||||
fileNodeKind(normal),
|
||||
typeof node.blobId === 'string' ? node.blobId : null,
|
||||
typeof node.size === 'number' ? node.size : null,
|
||||
JSON.stringify(node),
|
||||
gen,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function removeNode(db: StorageAdapter, jmap: string, id: string): void {
|
||||
db.run('DELETE FROM file_node WHERE jmap = ? AND id = ?', [jmap, id]);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Migration } from '../storage';
|
||||
|
||||
export const FILE_MIGRATIONS: readonly Migration[] = [
|
||||
{
|
||||
id: 700,
|
||||
name: 'files',
|
||||
sql: `
|
||||
CREATE TABLE file_node (
|
||||
jmap TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
-- file, directory or symlink, whichever way the server says so.
|
||||
kind TEXT NOT NULL,
|
||||
blob_id TEXT,
|
||||
size INTEGER,
|
||||
json TEXT NOT NULL,
|
||||
gen INTEGER NOT NULL DEFAULT 0,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
-- When the page last opened it. Cleared when its content is pushed out.
|
||||
opened_at INTEGER,
|
||||
-- Why the content isn't kept: tooLarge, overLimit, notAllowed or download.
|
||||
keep_error TEXT,
|
||||
PRIMARY KEY (jmap, id)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX file_node_parent ON file_node (jmap, parent_id);
|
||||
CREATE INDEX file_node_blob ON file_node (jmap, blob_id);
|
||||
CREATE TABLE file_blob (
|
||||
jmap TEXT NOT NULL,
|
||||
blob_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
kept_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (jmap, blob_id)
|
||||
) WITHOUT ROWID;
|
||||
-- In pieces: a whole file bound at once would grow the WASM heap by its
|
||||
-- size, and that memory never comes back.
|
||||
CREATE TABLE file_blob_chunk (
|
||||
jmap TEXT NOT NULL,
|
||||
blob_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
PRIMARY KEY (jmap, blob_id, seq)
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* FileNode metadata for one JMAP account, then the contents worth keeping.
|
||||
*
|
||||
* A full pass reads the tree the way the client does (fetchAllFileNodes,
|
||||
* lib/jmap/client.ts). `FileNode/get` with ids null returns everything, but
|
||||
* 0.16 silently stops at maxObjectsInGet and 1.0 refuses a larger account with
|
||||
* requestTooLarge. Past that, ids come from paging FileNode/query. Before
|
||||
* 0.16.6 that query leaves out folders, so parents still unknown afterwards are
|
||||
* read by id. The cursor is in sync_state, so a reload continues.
|
||||
*/
|
||||
|
||||
import type { SyncContext, Syncer } from '../area';
|
||||
import { runChangesLoop } from '../engine/changes';
|
||||
import { JmapMethodError, responseFor, type MethodCall } from '../engine/transport';
|
||||
import { dropOrphanBlobs, fetchKeptFiles } from './keep';
|
||||
import { FILE_NODE_PROPERTIES, FILES_CAPABILITY, FILES_USING, removeNode, writeNode, type WireNode } from './records';
|
||||
|
||||
const MAX_GET = 500;
|
||||
const GETS_PER_REQUEST = 8;
|
||||
const QUERY_PAGE = 5000;
|
||||
|
||||
function getBatch(ctx: SyncContext): number {
|
||||
const core = ctx.session.capabilities['urn:ietf:params:jmap:core'] as { maxObjectsInGet?: number } | undefined;
|
||||
return Math.min(MAX_GET, core?.maxObjectsInGet || MAX_GET);
|
||||
}
|
||||
|
||||
async function fetchNodes(ctx: SyncContext, jmap: string, ids: string[]): Promise<WireNode[]> {
|
||||
const size = getBatch(ctx);
|
||||
const nodes: WireNode[] = [];
|
||||
for (let at = 0; at < ids.length;) {
|
||||
const calls: MethodCall[] = [];
|
||||
for (let i = 0; i < GETS_PER_REQUEST && at < ids.length; i += 1, at += size) {
|
||||
calls.push(['FileNode/get', { accountId: jmap, ids: ids.slice(at, at + size), properties: FILE_NODE_PROPERTIES }, `g${i}`]);
|
||||
}
|
||||
const response = await ctx.transport.request(calls, FILES_USING);
|
||||
calls.forEach(([, , callId]) => nodes.push(...responseFor(response, callId)[1].list as WireNode[]));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
interface ListCursor { step: 'all' | 'query' | 'parents'; position: number; gen: number; captured: string }
|
||||
|
||||
async function startFullList(ctx: SyncContext, jmap: string): Promise<ListCursor> {
|
||||
const response = await ctx.transport.request([['FileNode/get', { accountId: jmap, ids: [], properties: ['id'] }, 's']], FILES_USING);
|
||||
const captured = String(responseFor(response, 's')[1].state);
|
||||
const gen = Number(ctx.db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM file_node WHERE jmap = ?', [jmap])?.gen ?? 0) + 1;
|
||||
const cursor: ListCursor = { step: 'all', position: 0, gen, captured };
|
||||
ctx.db.transaction(() => ctx.setSyncState(jmap, 'FileNode', { state: null, phase: 'list', cursor: { ...cursor } }));
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function saveStep(ctx: SyncContext, jmap: string, nodes: WireNode[], cursor: ListCursor): void {
|
||||
ctx.db.transaction(() => {
|
||||
for (const node of nodes) writeNode(ctx.db, jmap, node, cursor.gen);
|
||||
ctx.setSyncState(jmap, 'FileNode', { cursor: { ...cursor } });
|
||||
});
|
||||
}
|
||||
|
||||
async function runFullList(ctx: SyncContext, jmap: string, start: ListCursor): Promise<boolean> {
|
||||
const cursor = { ...start };
|
||||
|
||||
if (cursor.step === 'all') {
|
||||
let nodes: WireNode[] = [];
|
||||
let complete = false;
|
||||
try {
|
||||
const response = await ctx.transport.request([['FileNode/get', { accountId: jmap, ids: null, properties: FILE_NODE_PROPERTIES }, 'a']], FILES_USING);
|
||||
nodes = responseFor(response, 'a')[1].list as WireNode[];
|
||||
complete = nodes.length < getBatch(ctx);
|
||||
} catch (error) {
|
||||
if (!(error instanceof JmapMethodError && error.type === 'requestTooLarge')) throw error;
|
||||
}
|
||||
if (!complete) cursor.step = 'query';
|
||||
else cursor.step = 'parents';
|
||||
saveStep(ctx, jmap, nodes, cursor);
|
||||
}
|
||||
|
||||
while (cursor.step === 'query') {
|
||||
if (ctx.stopped()) return false;
|
||||
const response = await ctx.transport.request([['FileNode/query', { accountId: jmap, filter: {}, position: cursor.position, limit: QUERY_PAGE, calculateTotal: true }, 'q']], FILES_USING);
|
||||
const [, query] = responseFor(response, 'q');
|
||||
const ids = query.ids as string[];
|
||||
const missing = ids.filter((id) => Number(ctx.db.get<{ gen: number }>('SELECT gen FROM file_node WHERE jmap = ? AND id = ?', [jmap, id])?.gen ?? -1) < cursor.gen);
|
||||
const nodes = await fetchNodes(ctx, jmap, missing);
|
||||
cursor.position += ids.length;
|
||||
// The server may clamp the limit, so only an empty page or the total ends this.
|
||||
if (ids.length === 0 || (typeof query.total === 'number' && cursor.position >= query.total)) cursor.step = 'parents';
|
||||
saveStep(ctx, jmap, nodes, cursor);
|
||||
ctx.emit({ type: 'progress', account: ctx.account, area: 'files', phase: 'list', done: cursor.position, total: typeof query.total === 'number' ? query.total : null });
|
||||
}
|
||||
|
||||
const asked = new Set<string>();
|
||||
for (;;) {
|
||||
if (ctx.stopped()) return false;
|
||||
const parents = ctx.db.all<{ id: string }>(
|
||||
`SELECT DISTINCT n.parent_id AS id FROM file_node n
|
||||
WHERE n.jmap = ? AND n.gen = ? AND n.parent_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM file_node p WHERE p.jmap = n.jmap AND p.id = n.parent_id AND p.gen = ?)`,
|
||||
[jmap, cursor.gen, cursor.gen],
|
||||
).map((row) => String(row.id)).filter((id) => !asked.has(id));
|
||||
if (parents.length === 0) break;
|
||||
for (const id of parents) asked.add(id);
|
||||
saveStep(ctx, jmap, await fetchNodes(ctx, jmap, parents), cursor);
|
||||
}
|
||||
|
||||
ctx.db.transaction(() => {
|
||||
// Whatever this pass didn't see is gone from the server.
|
||||
ctx.db.run('DELETE FROM file_node WHERE jmap = ? AND gen < ?', [jmap, cursor.gen]);
|
||||
ctx.setSyncState(jmap, 'FileNode', { state: cursor.captured, phase: null, cursor: null });
|
||||
});
|
||||
ctx.emit({ type: 'changed', account: ctx.account, collections: ['FileNode'] });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function syncDelta(ctx: SyncContext, jmap: string, since: string, target: string | null): Promise<'done' | 'resync' | 'stopped'> {
|
||||
const outcome = await runChangesLoop({
|
||||
transport: ctx.transport,
|
||||
type: 'FileNode',
|
||||
jmap,
|
||||
using: FILES_USING,
|
||||
since,
|
||||
target,
|
||||
stopped: ctx.stopped,
|
||||
apply: async (page) => {
|
||||
const nodes = await fetchNodes(ctx, jmap, [...page.created, ...page.updated]);
|
||||
ctx.db.transaction(() => {
|
||||
const gen = Number(ctx.db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM file_node WHERE jmap = ?', [jmap])?.gen ?? 0);
|
||||
for (const node of nodes) writeNode(ctx.db, jmap, node, gen);
|
||||
for (const id of page.destroyed) removeNode(ctx.db, jmap, id);
|
||||
ctx.setSyncState(jmap, 'FileNode', { state: page.newState });
|
||||
});
|
||||
if (nodes.length + page.destroyed.length > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['FileNode'] });
|
||||
},
|
||||
});
|
||||
if (outcome.outcome === 'resync') return 'resync';
|
||||
return outcome.outcome === 'stopped' ? 'stopped' : 'done';
|
||||
}
|
||||
|
||||
export const filesSyncer: Syncer = {
|
||||
name: 'files',
|
||||
capability: FILES_CAPABILITY,
|
||||
async run(ctx, jmap) {
|
||||
const row = ctx.syncState(jmap, 'FileNode');
|
||||
let cursor = row.phase === 'list' ? row.cursor as unknown as ListCursor : null;
|
||||
if (!cursor && !row.state) cursor = await startFullList(ctx, jmap);
|
||||
if (!cursor && row.state) {
|
||||
const outcome = await syncDelta(ctx, jmap, row.state, row.target);
|
||||
if (outcome === 'stopped') return;
|
||||
if (outcome === 'resync') cursor = await startFullList(ctx, jmap);
|
||||
}
|
||||
if (cursor) {
|
||||
if (!(await runFullList(ctx, jmap, cursor))) return;
|
||||
// Catch what changed while paging.
|
||||
const after = ctx.syncState(jmap, 'FileNode');
|
||||
if (after.state && (await syncDelta(ctx, jmap, after.state, after.target)) === 'stopped') return;
|
||||
}
|
||||
if (ctx.stopped()) return;
|
||||
ctx.db.transaction(() => dropOrphanBlobs(ctx.db));
|
||||
await fetchKeptFiles(ctx, jmap);
|
||||
},
|
||||
};
|
||||
@@ -52,6 +52,13 @@ interface Part {
|
||||
raw: number;
|
||||
}
|
||||
|
||||
type KeptContext = Pick<StoreContext, 'db' | 'scope'> & Partial<Pick<StoreContext, 'keptByOthers'>>;
|
||||
|
||||
/** The total is for everything an account keeps, so what the Files area holds takes its part. */
|
||||
function mailCap(ctx: KeptContext): number {
|
||||
return Math.max(0, ctx.scope.maxTotalBytes - (ctx.keptByOthers?.('mail') ?? 0));
|
||||
}
|
||||
|
||||
function usedBytes(db: StorageAdapter): number {
|
||||
return Number(db.get<{ n: number | null }>('SELECT SUM(size) AS n FROM blob')?.n ?? 0);
|
||||
}
|
||||
@@ -97,7 +104,7 @@ function makeRoom(db: StorageAdapter, cap: number, size: number, at: number, raw
|
||||
}
|
||||
|
||||
/** A tighter scope applies at once, not at the next sync. */
|
||||
export function applyAttachmentScope(ctx: Pick<StoreContext, 'db' | 'scope'>): void {
|
||||
export function applyAttachmentScope(ctx: KeptContext): void {
|
||||
const { db, scope } = ctx;
|
||||
db.transaction(() => {
|
||||
if (!scope.attachments) {
|
||||
@@ -108,7 +115,7 @@ export function applyAttachmentScope(ctx: Pick<StoreContext, 'db' | 'scope'>): v
|
||||
for (const row of db.all<{ jmap: string; blob_id: string }>('SELECT jmap, blob_id FROM blob WHERE size > ?', [scope.maxAttachmentBytes])) {
|
||||
dropBlob(db, row.jmap, row.blob_id);
|
||||
}
|
||||
pushOut(db, scope.maxTotalBytes, 0, Number.MAX_SAFE_INTEGER);
|
||||
pushOut(db, mailCap(ctx), 0, Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,13 +142,13 @@ export function keptBlob(ctx: Pick<StoreContext, 'db' | 'scope'>, jmap: string,
|
||||
}
|
||||
|
||||
/** A file the user opened online: kept if it belongs to a kept message and the scope allows it. */
|
||||
export function recordKeptBlob(ctx: Pick<StoreContext, 'db' | 'scope'>, jmap: string, blobId: string, type: string | null, data: Uint8Array): boolean {
|
||||
export function recordKeptBlob(ctx: KeptContext, jmap: string, blobId: string, type: string | null, data: Uint8Array): boolean {
|
||||
const { db, scope } = ctx;
|
||||
const owner = db.get<{ received_at: number; raw: number }>('SELECT received_at, raw FROM email_part WHERE jmap = ? AND blob_id = ?', [jmap, blobId]);
|
||||
if (!owner || !scope.attachments || data.byteLength > scope.maxAttachmentBytes) return false;
|
||||
const at = Number(owner.received_at);
|
||||
return db.transaction(() => {
|
||||
if (!makeRoom(db, scope.maxTotalBytes, data.byteLength, at, Boolean(owner.raw))) return false;
|
||||
if (!makeRoom(db, mailCap(ctx), data.byteLength, at, Boolean(owner.raw))) return false;
|
||||
putBlob(db, jmap, blobId, type, data, at);
|
||||
return true;
|
||||
});
|
||||
@@ -264,7 +271,7 @@ async function fetchOne(ctx: SyncContext, jmap: string, part: Part): Promise<Fet
|
||||
if (isKept(db, jmap, blobId)) return 'kept';
|
||||
// Its message may have gone while this was on its way, or the rules changed.
|
||||
const wanted = ctx.scope.attachments && db.get('SELECT 1 AS x FROM email_part WHERE jmap = ? AND blob_id = ?', [jmap, blobId]);
|
||||
if (!wanted || !makeRoom(db, ctx.scope.maxTotalBytes, size, at, Boolean(part.raw))) {
|
||||
if (!wanted || !makeRoom(db, mailCap(ctx), size, at, Boolean(part.raw))) {
|
||||
clearUnfinished(db, jmap, blobId);
|
||||
return 'skipped';
|
||||
}
|
||||
@@ -301,7 +308,7 @@ async function fetchKept(ctx: SyncContext, jmap: string): Promise<void> {
|
||||
if (ctx.stopped() || ctx.syncWanted()) return;
|
||||
// The same file a second time in this page: kept by now.
|
||||
if (db.get('SELECT 1 AS x FROM blob WHERE jmap = ? AND blob_id = ?', [jmap, String(part.blob_id)])) continue;
|
||||
if (!couldFit(db, ctx.scope.maxTotalBytes, Number(part.size), Number(part.received_at), Boolean(part.raw))) {
|
||||
if (!couldFit(db, mailCap(ctx), Number(part.size), Number(part.received_at), Boolean(part.raw))) {
|
||||
passed += 1;
|
||||
passedInARow += 1;
|
||||
if (passedInARow >= MOST_PASSED_IN_A_ROW) return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { applyAttachmentScope, attachmentSyncer } from './attachments';
|
||||
import { MAIL_CALLS } from './calls';
|
||||
import { MAIL_METHODS } from './methods';
|
||||
import { MAIL_MIGRATIONS } from './schema';
|
||||
import { applyMailScope, mailUsage } from './scope';
|
||||
import { SEARCH_MIGRATIONS } from './search-schema';
|
||||
import { identitySyncer, mailSyncer, quotaSyncer } from './sync';
|
||||
|
||||
@@ -12,5 +13,8 @@ export const mailArea: StoreArea = {
|
||||
methods: MAIL_METHODS,
|
||||
syncers: [mailSyncer, identitySyncer, quotaSyncer, attachmentSyncer],
|
||||
calls: MAIL_CALLS,
|
||||
applyScope: applyMailScope,
|
||||
usage: mailUsage,
|
||||
keptBytes: (ctx) => Number(ctx.db.get<{ n: number | null }>('SELECT SUM(size) AS n FROM blob')?.n ?? 0),
|
||||
scopeChanged: applyAttachmentScope,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* What a changed scope means for kept mail. Tightened: what falls outside goes
|
||||
* right away, without the network and without fetching what stays. Loosened:
|
||||
* the next pass lists the wider window, keeping every body already here.
|
||||
*/
|
||||
|
||||
import type { ScopeChange, StoreUsagePart, SyncContext } from '../area';
|
||||
import { deleteEmailRow } from './records';
|
||||
|
||||
const DELETE_BATCH = 200;
|
||||
|
||||
/** Other calls and syncs get a turn between batches. */
|
||||
const breathe = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
function cutoff(ctx: SyncContext, days: number): number {
|
||||
return ctx.now() - days * 86_400_000;
|
||||
}
|
||||
|
||||
function widened(previous: number | null, next: number | null): boolean {
|
||||
if (previous === null) return false;
|
||||
return next === null || next > previous;
|
||||
}
|
||||
|
||||
async function pruneWindow(ctx: SyncContext, days: number): Promise<void> {
|
||||
const before = cutoff(ctx, days);
|
||||
let removed = false;
|
||||
for (const { jmap } of ctx.db.all<{ jmap: string }>('SELECT DISTINCT jmap FROM email')) {
|
||||
// A draft only on this device has no other copy. A change waiting for any
|
||||
// other message goes out by its id, whether it's kept or not.
|
||||
const spared = ctx.sparedRecords(jmap, 'Email');
|
||||
const notSpared = spared.length > 0 ? `AND id NOT IN (${spared.map(() => '?').join(', ')})` : '';
|
||||
for (;;) {
|
||||
if (ctx.stopped()) return;
|
||||
const rows = ctx.db.all<{ rowid: number }>(`SELECT rowid FROM email WHERE jmap = ? AND received_at < ? ${notSpared} LIMIT ?`, [jmap, before, ...spared, DELETE_BATCH]);
|
||||
if (rows.length === 0) break;
|
||||
ctx.db.transaction(() => {
|
||||
for (const row of rows) deleteEmailRow(ctx.db, Number(row.rowid));
|
||||
});
|
||||
removed = true;
|
||||
await breathe();
|
||||
}
|
||||
}
|
||||
if (removed) ctx.emit({ type: 'changed', account: ctx.account, collections: ['Email'] });
|
||||
}
|
||||
|
||||
export async function applyMailScope(ctx: SyncContext, { previous, next }: ScopeChange): Promise<void> {
|
||||
// First, in case this gets interrupted: a pass under way pages by the old
|
||||
// window, so it starts over, tighter or wider.
|
||||
if (previous && previous.days !== next.days) {
|
||||
ctx.db.transaction(() => {
|
||||
for (const { jmap, state, phase } of ctx.db.all<{ jmap: string; state: string | null; phase: string | null }>("SELECT jmap, state, phase FROM sync_state WHERE type = 'Email'")) {
|
||||
if (phase === 'list' || (state && widened(previous.days, next.days))) ctx.setSyncState(jmap, 'Email', { state: null, phase: null, cursor: null });
|
||||
}
|
||||
});
|
||||
}
|
||||
if (next.days !== null) await pruneWindow(ctx, next.days);
|
||||
}
|
||||
|
||||
/** Message text and the search index aren't counted here: they are what's left of the file. */
|
||||
export function mailUsage(ctx: Pick<SyncContext, 'db'>): StoreUsagePart[] {
|
||||
const blobs = ctx.db.get<{ bytes: number | null; items: number }>('SELECT SUM(size) AS bytes, COUNT(*) AS items FROM blob');
|
||||
const messages = ctx.db.get<{ items: number }>('SELECT COUNT(*) AS items FROM email');
|
||||
return [
|
||||
{ part: 'mail', bytes: 0, items: Number(messages?.items ?? 0) },
|
||||
{ part: 'attachments', bytes: Number(blobs?.bytes ?? 0), items: Number(blobs?.items ?? 0) },
|
||||
];
|
||||
}
|
||||
@@ -16,4 +16,8 @@ export const outboxArea: StoreArea = {
|
||||
syncers: [],
|
||||
calls: { ...OUTBOX_CALLS, ...DRAFT_CALLS },
|
||||
overlay,
|
||||
// A draft only on this device has no other copy, however old it is.
|
||||
spares: (ctx, jmap, type) => (type === 'Email'
|
||||
? ctx.db.all<{ local_id: string }>('SELECT local_id FROM draft_pending WHERE jmap = ?', [jmap]).map((row) => row.local_id)
|
||||
: []),
|
||||
};
|
||||
|
||||
@@ -54,13 +54,19 @@ export class StoreHost {
|
||||
if (existing) {
|
||||
if (params.session) this.setSession(params.account, params.session);
|
||||
this.setAuth(params.account, params.auth);
|
||||
existing.engine.setScope(params.scope);
|
||||
if (params.scope) existing.engine.setScope(params.scope);
|
||||
if (params.invertedKeywordSort !== null) existing.engine.setInvertedKeywordSort(params.invertedKeywordSort);
|
||||
void existing.engine.requestSync();
|
||||
return { created: false };
|
||||
}
|
||||
const db = await this.options.storage.open(params.file, params.keyHex);
|
||||
const created = !db.get("SELECT 1 AS yes FROM sqlite_master WHERE name = 'schema_migration'");
|
||||
// Nothing to sync with yet. No file stays behind, or the next open couldn't tell that this one was new.
|
||||
if (created && !params.session) {
|
||||
db.close();
|
||||
this.options.storage.remove(params.file);
|
||||
throw new Error('No session for this account yet');
|
||||
}
|
||||
const session = params.session ?? AccountEngine.storedSession(db);
|
||||
if (!session) {
|
||||
db.close();
|
||||
@@ -111,8 +117,7 @@ export class StoreHost {
|
||||
setScope(account: string, scope: Parameters<AccountEngine['setScope']>[0]): void {
|
||||
const open = this.accounts.get(account);
|
||||
if (!open) return;
|
||||
open.engine.setScope(scope);
|
||||
void open.engine.requestSync();
|
||||
if (open.engine.setScope(scope)) void open.engine.requestSync();
|
||||
}
|
||||
|
||||
setInvertedKeywordSort(account: string, inverted: boolean): void {
|
||||
@@ -131,7 +136,9 @@ export class StoreHost {
|
||||
async removeAccount(account: string, file: string): Promise<boolean> {
|
||||
await this.closeAccount(account);
|
||||
this.synced.delete(account);
|
||||
return this.options.storage.remove(file);
|
||||
const removed = this.options.storage.remove(file);
|
||||
this.options.emit({ type: 'removed', account });
|
||||
return removed;
|
||||
}
|
||||
|
||||
async closeAll(): Promise<void> {
|
||||
@@ -173,6 +180,12 @@ export class StoreHost {
|
||||
if (!open) throw accountClosed();
|
||||
return open.engine.areaCall(name, callParams);
|
||||
}
|
||||
case 'setScope': {
|
||||
const { account, scope } = params as Parameters<StoreRpc['setScope']>[0];
|
||||
if (!this.accounts.has(account)) throw accountClosed();
|
||||
this.setScope(account, scope);
|
||||
return undefined;
|
||||
}
|
||||
case 'removeAccount': {
|
||||
const { account, file } = params as Parameters<StoreRpc['removeAccount']>[0];
|
||||
return this.removeAccount(account, file);
|
||||
|
||||
@@ -15,7 +15,8 @@ export interface OpenAccountParams {
|
||||
/** 32 bytes in hex. Only ever sent to this tab's own worker, never over the channel. */
|
||||
keyHex: string;
|
||||
session: StoreSession | null;
|
||||
scope: StoreScope;
|
||||
/** Undefined while the page can't tell (no policy yet): the store keeps the last one. */
|
||||
scope?: StoreScope;
|
||||
/** Null when this tab's client hasn't probed it: the stored answer stays. */
|
||||
invertedKeywordSort: boolean | null;
|
||||
/** The page's current Authorization header. */
|
||||
@@ -33,6 +34,8 @@ export interface StoreRpc {
|
||||
area(params: { account: string; name: string; params: unknown }): unknown;
|
||||
/** Closes an account and deletes its file. The name holds no account data. */
|
||||
removeAccount(params: { account: string; file: string }): boolean;
|
||||
/** How far back to keep mail, and what else. Any tab may say, since a policy reaches every tab. */
|
||||
setScope(params: { account: string; scope: StoreScope }): void;
|
||||
}
|
||||
|
||||
export interface StoreStatus {
|
||||
@@ -79,7 +82,6 @@ export type PageToWorker =
|
||||
| { kind: 'set-auth'; account: string; auth: string | null }
|
||||
| { kind: 'set-inverted'; account: string; inverted: boolean }
|
||||
| { kind: 'set-session'; account: string; session: StoreSession }
|
||||
| { kind: 'set-scope'; account: string; scope: StoreScope }
|
||||
| { kind: 'release'; id: string };
|
||||
|
||||
export type WorkerToPage =
|
||||
|
||||
@@ -137,9 +137,6 @@ scope.addEventListener('message', async (event) => {
|
||||
case 'set-session':
|
||||
host?.setSession(message.account, message.session);
|
||||
break;
|
||||
case 'set-scope':
|
||||
host?.setScope(message.account, message.scope);
|
||||
break;
|
||||
case 'set-inverted':
|
||||
host?.setInvertedKeywordSort(message.account, message.inverted);
|
||||
break;
|
||||
|
||||
@@ -5780,6 +5780,67 @@
|
||||
"confirm_accounts": "يتم تسجيل الخروج من كل الحسابات",
|
||||
"confirm_button": "إزالة كل شيء"
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "يحتفظ ببريدك وتقويمك وجهات اتصالك والملفات التي تختارها، مشفّرة على هذا الجهاز. دون اتصال يمكنك قراءة البريد ووضع علامات عليه ونقله، وما تكتبه ينتظر في صندوق الصادر. يؤدي تسجيل الخروج إلى إزالة كل ذلك.",
|
||||
"window_label": "إلى أي مدة سابقة يُحتفظ بالبريد",
|
||||
"window_description": "يُزال البريد وأحداث التقويم الأقدم من هذا الجهاز. ما زال بإمكانك فتحها عندما يكون لديك اتصال.",
|
||||
"window_days": "{days, plural, zero {# يوم} one {يوم واحد} two {يومان} few {# أيام} many {# يومًا} other {# يوم}}",
|
||||
"window_year": "سنة واحدة",
|
||||
"window_everything": "الكل",
|
||||
"attachments_label": "الاحتفاظ بالمرفقات",
|
||||
"attachments_description": "مرفقات الرسائل المحفوظة والملفات التي تحتفظ بها دون اتصال، حتى {total} في المجموع. تُزال الأقدم أولًا.",
|
||||
"total_locked": "تسمح مؤسستك بحد أقصى {size} من المرفقات والملفات.",
|
||||
"attachment_cap_label": "أكبر مرفق يُحتفظ به",
|
||||
"usage_mail": "الرسائل وفهرس البحث",
|
||||
"usage_attachments": "المرفقات",
|
||||
"usage_calendar": "التقويم",
|
||||
"usage_contacts": "جهات الاتصال",
|
||||
"usage_files": "الملفات المحفوظة دون اتصال",
|
||||
"usage_row": "{size} ({count, plural, zero {# عنصر} one {عنصر واحد} two {عنصران} few {# عناصر} many {# عنصرًا} other {# عنصر}})",
|
||||
"progress_list": "جارٍ سرد الرسائل",
|
||||
"progress_bodies": "جارٍ تنزيل الرسائل",
|
||||
"progress_threads": "جارٍ ترتيب المحادثات",
|
||||
"progress_files": "جارٍ تنزيل الملفات",
|
||||
"progress_other": "جارٍ جلب البيانات إلى هذا الجهاز",
|
||||
"progress_count": "{done} من {total}",
|
||||
"progress_count_open": "{done} حتى الآن",
|
||||
"progress_left": "متبقٍّ نحو {minutes, plural, zero {# دقيقة} one {دقيقة واحدة} two {دقيقتين} few {# دقائق} many {# دقيقة} other {# دقيقة}}",
|
||||
"clear_label": "مسح هذا الحساب",
|
||||
"clear_description": "يحذف كل ما يحتفظ به هذا الجهاز لهذا الحساب، ثم ينزّله من جديد.",
|
||||
"clear_button": "مسح…",
|
||||
"clear_confirm_title": "هل تريد مسح هذا الحساب على هذا الجهاز؟",
|
||||
"clear_confirm_message": "سيُحذف كل ما يحتفظ به هذا الجهاز للحساب {account} ثم يُنزَّل من جديد. يبقى بريدك على الخادم.",
|
||||
"clear_confirm_unsent": "{count, plural, zero {لن تُفقد أي رسالة لم تُرسل.} one {ستُفقد رسالة واحدة لم تُرسل.} two {ستُفقد رسالتان لم تُرسلا.} few {ستُفقد # رسائل لم تُرسل.} many {ستُفقد # رسالة لم تُرسل.} other {ستُفقد # رسالة لم تُرسل.}}",
|
||||
"clear_confirm": "مسح",
|
||||
"persist_refused": "قد يحذف هذا المتصفح البريد المحفوظ عندما تقل المساحة. تثبيت التطبيق يمنع ذلك عادةً.",
|
||||
"persist_retry": "اسأل مرة أخرى",
|
||||
"safari_hint": "يحذف Safari بيانات الموقع بعد 7 أيام دون زيارة. أضف Bulwark إلى الشاشة الرئيسية أو إلى Dock لتبقى رسائلك على هذا الجهاز.",
|
||||
"evicted": "حذف المتصفح البريد المحفوظ على هذا الجهاز. يجري تنزيله من جديد.",
|
||||
"evicted_lost": "{count, plural, zero {لم تُفقد أي رسالة أو تغيير لم يُرسل.} one {فُقدت رسالة أو تغيير واحد لم يُرسل.} two {فُقدت رسالتان أو تغييران لم يُرسلا.} few {فُقدت # رسائل أو تغييرات لم تُرسل.} many {فُقدت # رسالة أو تغييرًا لم يُرسل.} other {فُقدت # رسالة أو تغيير لم يُرسل.}}",
|
||||
"evicted_dismiss": "حسنًا",
|
||||
"storage_full_title": "مساحة تخزين الجهاز ممتلئة.",
|
||||
"storage_full_text": "توقف Bulwark عن حفظ البريد هنا. احتفظ ببريد أقل أو امسح هذا الحساب لتوفير مساحة.",
|
||||
"open_settings": "فتح الإعدادات",
|
||||
"default_on": "يحتفظ Bulwark ببريدك على هذا الجهاز ليعمل دون اتصال. يمكنك تغيير ذلك من الإعدادات.",
|
||||
"conflict_calendar": "حدث في التقويم",
|
||||
"conflict_contacts": "جهة اتصال",
|
||||
"kept_files_title": "الملفات المحفوظة دون اتصال",
|
||||
"kept_state_kept": "على هذا الجهاز",
|
||||
"kept_state_waiting": "بانتظار التنزيل",
|
||||
"kept_error_tooLarge": "كبير جدًا للاحتفاظ به",
|
||||
"kept_error_overLimit": "يتجاوز حد هذا الجهاز",
|
||||
"kept_error_notAllowed": "لا تسمح مؤسستك بالاحتفاظ بالملفات",
|
||||
"kept_error_download": "تعذّر التنزيل",
|
||||
"keep_offline": "الاحتفاظ دون اتصال",
|
||||
"unpin": "إيقاف الاحتفاظ دون اتصال",
|
||||
"unpin_label": "إيقاف الاحتفاظ بـ {name} دون اتصال",
|
||||
"warning_desktop": "يمكن لأي شخص يستخدم حساب المستخدم على هذا الحاسوب أن يقرأ هذا البريد. استخدم تشفير القرص.",
|
||||
"off_confirm_text": "سيؤدي هذا إلى حذف كل ما يحتفظ به هذا الجهاز للحساب {account}. يبقى بريدك على الخادم.",
|
||||
"unsent_changes": "{count, plural, zero {لن تُفقد أي تغييرات لم تصل إلى الخادم.} one {سيُفقد تغيير واحد لم يصل إلى الخادم.} two {سيُفقد تغييران لم يصلا إلى الخادم.} few {ستُفقد # تغييرات لم تصل إلى الخادم.} many {سيُفقد # تغييرًا لم يصل إلى الخادم.} other {سيُفقد # تغيير لم يصل إلى الخادم.}}",
|
||||
"clear_needs_connection": "يحتاج هذا إلى اتصال، لأن كل شيء يُنزَّل من جديد.",
|
||||
"usage_label": "ما يحتفظ به هذا الجهاز",
|
||||
"pin_not_ready": "هذا الملف ليس في قائمة هذا الجهاز بعد. جرّب مرة أخرى بعد قليل."
|
||||
},
|
||||
"offlineDrafts": {
|
||||
"title": "المسودات على هذا الجهاز",
|
||||
"description": "هذه المسودات محفوظة على هذا الجهاز. ستنتقل إلى الخادم تلقائيًا فور إمكان الوصول إليه.",
|
||||
|
||||
@@ -5780,6 +5780,67 @@
|
||||
"confirm_accounts": "Es tanca la sessió de tots els comptes",
|
||||
"confirm_button": "Treu-ho tot"
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Desa el vostre correu, calendari, contactes i els fitxers que trieu, xifrats en aquest dispositiu. Sense connexió podeu llegir, marcar i moure correu, i el que escriviu espera a la safata de sortida. En tancar la sessió s'esborra tot.",
|
||||
"window_label": "Fins a quan enrere desar",
|
||||
"window_description": "El correu i els esdeveniments més antics surten d'aquest dispositiu. Encara els podeu obrir quan tingueu connexió.",
|
||||
"window_days": "{days, plural, one {# dia} other {# dies}}",
|
||||
"window_year": "1 any",
|
||||
"window_everything": "Tot",
|
||||
"attachments_label": "Desa els fitxers adjunts",
|
||||
"attachments_description": "Fitxers adjunts dels missatges desats i fitxers que deseu sense connexió, fins a {total} en total. Els més antics se suprimeixen primer.",
|
||||
"total_locked": "La vostra organització permet com a màxim {size} d'adjunts i fitxers.",
|
||||
"attachment_cap_label": "Adjunt més gran que es desa",
|
||||
"usage_mail": "Missatges i índex de cerca",
|
||||
"usage_attachments": "Fitxers adjunts",
|
||||
"usage_calendar": "Calendari",
|
||||
"usage_contacts": "Contactes",
|
||||
"usage_files": "Fitxers desats sense connexió",
|
||||
"usage_row": "{size} ({count, plural, one {# element} other {# elements}})",
|
||||
"progress_list": "Llistant els missatges",
|
||||
"progress_bodies": "Baixant els missatges",
|
||||
"progress_threads": "Ordenant les converses",
|
||||
"progress_files": "Baixant els fitxers",
|
||||
"progress_other": "Portant dades a aquest dispositiu",
|
||||
"progress_count": "{done} de {total}",
|
||||
"progress_count_open": "{done} fins ara",
|
||||
"progress_left": "queden uns {minutes, plural, one {# minut} other {# minuts}}",
|
||||
"clear_label": "Buida aquest compte",
|
||||
"clear_description": "Suprimeix tot el que aquest dispositiu desa d'aquest compte i ho torna a baixar.",
|
||||
"clear_button": "Buida…",
|
||||
"clear_confirm_title": "Voleu buidar aquest compte en aquest dispositiu?",
|
||||
"clear_confirm_message": "Tot el que aquest dispositiu desa de {account} se suprimeix i es torna a baixar. El vostre correu al servidor no es toca.",
|
||||
"clear_confirm_unsent": "{count, plural, one {Es perdrà # missatge sense enviar.} other {Es perdran # missatges sense enviar.}}",
|
||||
"clear_confirm": "Buida",
|
||||
"persist_refused": "Aquest navegador pot esborrar el correu desat quan falti espai. Instal·lar l'aplicació sol evitar-ho.",
|
||||
"persist_retry": "Torna-ho a demanar",
|
||||
"safari_hint": "El Safari esborra les dades d'un lloc després de 7 dies sense visitar-lo. Afegiu Bulwark a la pantalla d'inici o al Dock per mantenir el vostre correu en aquest dispositiu.",
|
||||
"evicted": "El navegador ha esborrat el correu desat en aquest dispositiu. Es torna a baixar.",
|
||||
"evicted_lost": "{count, plural, one {S'ha perdut # missatge o canvi sense enviar.} other {S'han perdut # missatges o canvis sense enviar.}}",
|
||||
"evicted_dismiss": "D'acord",
|
||||
"storage_full_title": "L'emmagatzematge del dispositiu és ple.",
|
||||
"storage_full_text": "Bulwark ha deixat de desar correu aquí. Deseu menys correu o buideu aquest compte per fer espai.",
|
||||
"open_settings": "Obre la configuració",
|
||||
"default_on": "Bulwark desa el vostre correu en aquest dispositiu perquè funcioni sense connexió. Podeu canviar-ho a la configuració.",
|
||||
"conflict_calendar": "Esdeveniment del calendari",
|
||||
"conflict_contacts": "Contacte",
|
||||
"kept_files_title": "Fitxers desats sense connexió",
|
||||
"kept_state_kept": "En aquest dispositiu",
|
||||
"kept_state_waiting": "Esperant la baixada",
|
||||
"kept_error_tooLarge": "Massa gran per desar-lo",
|
||||
"kept_error_overLimit": "Supera el límit d'aquest dispositiu",
|
||||
"kept_error_notAllowed": "La vostra organització no permet desar fitxers",
|
||||
"kept_error_download": "No s'ha pogut baixar",
|
||||
"keep_offline": "Desa sense connexió",
|
||||
"unpin": "Deixa de desar sense connexió",
|
||||
"unpin_label": "Deixa de desar {name} sense connexió",
|
||||
"warning_desktop": "Qualsevol persona que pugui fer servir el compte d'usuari d'aquest ordinador pot llegir aquest correu. Feu servir el xifratge de disc.",
|
||||
"off_confirm_text": "Això suprimeix tot el que aquest dispositiu desa de {account}. El vostre correu al servidor no es toca.",
|
||||
"unsent_changes": "{count, plural, one {Es perdrà # canvi que no ha arribat al servidor.} other {Es perdran # canvis que no han arribat al servidor.}}",
|
||||
"clear_needs_connection": "Cal una connexió, perquè tot es torna a baixar.",
|
||||
"usage_label": "Què desa aquest dispositiu",
|
||||
"pin_not_ready": "Aquest fitxer encara no és a la llista d'aquest dispositiu. Torneu-ho a provar d'aquí a un moment."
|
||||
},
|
||||
"offlineDrafts": {
|
||||
"title": "Esborranys en aquest dispositiu",
|
||||
"description": "Aquests esborranys estan desats en aquest dispositiu. Aniran al servidor sols tan bon punt s'hi pugui connectar.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user