From 79ae1d51f8460079b104038b86923aa15925ee9e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:46:59 +0100 Subject: [PATCH 01/35] feat: keep contacts on the device, search them offline and queue edits --- lib/store/__tests__/contacts.test.ts | 660 +++++++++++++++++++ lib/store/__tests__/helpers/fake-contacts.ts | 229 +++++++ lib/store/__tests__/helpers/fake-jmap.ts | 6 +- lib/store/area.ts | 2 + lib/store/areas.ts | 3 +- lib/store/contacts/index.ts | 27 + lib/store/contacts/methods.ts | 209 ++++++ lib/store/contacts/patch.ts | 88 +++ lib/store/contacts/pending.ts | 459 +++++++++++++ lib/store/contacts/records.ts | 116 ++++ lib/store/contacts/schema.ts | 75 +++ lib/store/contacts/sync.ts | 179 +++++ lib/store/engine/account-engine.ts | 2 + 13 files changed, 2051 insertions(+), 4 deletions(-) create mode 100644 lib/store/__tests__/contacts.test.ts create mode 100644 lib/store/__tests__/helpers/fake-contacts.ts create mode 100644 lib/store/contacts/index.ts create mode 100644 lib/store/contacts/methods.ts create mode 100644 lib/store/contacts/patch.ts create mode 100644 lib/store/contacts/pending.ts create mode 100644 lib/store/contacts/records.ts create mode 100644 lib/store/contacts/schema.ts create mode 100644 lib/store/contacts/sync.ts diff --git a/lib/store/__tests__/contacts.test.ts b/lib/store/__tests__/contacts.test.ts new file mode 100644 index 000000000..526595a85 --- /dev/null +++ b/lib/store/__tests__/contacts.test.ts @@ -0,0 +1,660 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { textFilter } from '@/lib/jmap/search-utils'; +import { DEFAULT_SCOPE, type StoreEvent } from '../area'; +import { contactsArea } from '../contacts'; +import { applyPatch, mergePatches } from '../contacts/patch'; +import { AccountEngine } from '../engine/account-engine'; +import type { MethodCall } from '../engine/transport'; +import type { StorageAdapter } from '../storage'; +import { JMAP_ACCOUNT } from './helpers/fake-jmap'; +import { CONTACTS_SESSION, FakeContacts, MAIL_ONLY_ACCOUNT, SHARED_ACCOUNT } from './helpers/fake-contacts'; +import { openFileStore, openMemoryStore } from './helpers/node-adapter'; + +function engineFor(server: FakeContacts, db: StorageAdapter = openMemoryStore(), emit?: (e: StoreEvent) => void) { + return new AccountEngine({ + account: 'a@fake.test', + db, + session: CONTACTS_SESSION, + transport: server, + areas: [contactsArea], + scope: DEFAULT_SCOPE, + emit, + now: () => Date.UTC(2026, 10, 3), + sleep: async () => {}, + }); +} + +async function synced(server: FakeContacts) { + const db = openMemoryStore(); + const engine = engineFor(server, db); + await engine.requestSync(); + return { db, engine }; +} + +function count(db: StorageAdapter, table: string): number { + return Number(db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM ${table}`)?.n); +} + +/* eslint-disable @typescript-eslint/no-explicit-any -- answers are shaped like the server's */ +async function call(engine: AccountEngine, name: string, args: Record): Promise { + 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; +} + +/** The client's searchContacts: its filter, limit 50, then a get on the query's ids. */ +async function search(engine: AccountEngine, query: string, accountId = JMAP_ACCOUNT): Promise { + const calls: MethodCall[] = [ + ['ContactCard/query', { accountId, filter: textFilter(query), limit: 50 }, '0'], + ['ContactCard/get', { accountId, '#ids': { resultOf: '0', name: 'ContactCard/query', path: '/ids' } }, '1'], + ]; + const response = await engine.answer(calls); + return (response?.methodResponses[1][1].list as Array<{ name: { full: string } }>).map((c) => c.name.full); +} + +function setCalls(server: FakeContacts): Array> { + return server.calls.filter(([name]) => name === 'ContactCard/set').map(([, args]) => args as Record); +} + +describe('contacts sync', () => { + it('pages every card of every account with the capability, and only those', async () => { + const server = new FakeContacts(); + server.addBook({ id: 'teambook', name: 'Team' }, SHARED_ACCOUNT); + for (let i = 0; i < 2345; i += 1) server.addCard(); + server.addCard({ id: 'c1', name: { full: 'Same id in the shared account' } }, SHARED_ACCOUNT); + server.addCard({}, MAIL_ONLY_ACCOUNT); + const { db } = await synced(server); + + expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM contact_card WHERE jmap = ?', [JMAP_ACCOUNT])?.n)).toBe(2345); + expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM contact_card WHERE jmap = ?', [SHARED_ACCOUNT])?.n)).toBe(1); + expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM contact_card WHERE jmap = ?', [MAIL_ONLY_ACCOUNT])?.n)).toBe(0); + // Same id in two accounts stays two cards. + const names = db.all<{ json: string }>("SELECT json FROM contact_card WHERE id = 'c1' ORDER BY jmap").map((r) => (JSON.parse(r.json) as { name: { full: string } }).name.full); + expect(names).toHaveLength(2); + for (const [name, args] of server.calls) { + if (name === 'ContactCard/query') expect(args.limit).toBeLessThanOrEqual(500); + } + }); + + it('continues a stopped full sync from its cursor after a reload', async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'store-contacts-')); + try { + const file = path.join(dir, 's.db'); + const server = new FakeContacts(); + for (let i = 0; i < 2600; i += 1) server.addCard(); + const first = openFileStore(file); + const engine = engineFor(server, first); + let queries = 0; + server.beforeRequest = () => { + queries += 1; + // The address book call and the state capture, then one page of 2000: the network goes. + if (queries === 4) server.offline = true; + }; + await engine.requestSync(); + await engine.close(); + expect(count(first, 'contact_card')).toBe(2000); + first.close(); + + server.offline = false; + server.beforeRequest = null; + server.calls.length = 0; + const second = openFileStore(file); + await engineFor(server, second).requestSync(); + expect(count(second, 'contact_card')).toBe(2600); + const positions = server.calls.filter(([name]) => name === 'ContactCard/query').map(([, args]) => args.position); + expect(positions[0]).toBe(2000); + second.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('applies created, updated and destroyed cards as deltas, across pages', async () => { + const server = new FakeContacts(); + const a = server.addCard({ name: { full: 'Anna' } }); + const b = server.addCard({ name: { full: 'Bernd' } }); + const { db, engine } = await synced(server); + server.calls.length = 0; + + for (let i = 0; i < 1200; i += 1) server.addCard(); + server.editCard(a.id, { name: { full: 'Anna Neu' } }); + server.removeCard(b.id); + await engine.requestSync(); + + expect(server.calls.filter(([name]) => name === 'ContactCard/changes').length).toBeGreaterThan(1); + expect(server.calls.some(([name]) => name === 'ContactCard/query')).toBe(false); + expect(count(db, 'contact_card')).toBe(1201); + expect(await search(engine, 'anna')).toEqual(['Anna Neu']); + expect(await search(engine, 'bernd')).toEqual([]); + }); + + it('follows address book changes', async () => { + const server = new FakeContacts(); + const { engine } = await synced(server); + server.addBook({ id: 'book2', name: 'Work' }); + await engine.requestSync(); + expect(server.calls.some(([name]) => name === 'AddressBook/changes')).toBe(true); + const books = await call(engine, 'AddressBook/get', {}); + expect(books.list.map((b: { id: string }) => b.id).sort()).toEqual(['book1', 'book2']); + }); + + it('starts over when the server can no longer give changes, and drops what it no longer has', async () => { + const server = new FakeContacts(); + const gone = server.addCard({ name: { full: 'Gone' } }); + const kept = server.addCard({ name: { full: 'Kept' } }); + const { db, engine } = await synced(server); + server.removeCard(gone.id); + server.account().coll.ContactCard.truncatedBelow = server.account().coll.ContactCard.log.length; + await engine.requestSync(); + expect(db.all('SELECT id FROM contact_card').map((r) => r.id)).toEqual([kept.id]); + }); + + it('keeps a queued create through a resync', async () => { + const server = new FakeContacts(); + server.addCard(); + const { db, engine } = await synced(server); + const { created } = await call(engine, 'ContactCard/set', { create: { n: { name: { full: 'Offline' }, addressBookIds: { book1: true } } } }); + // The create can't go out yet, and the sweep of a full sync must not take + // it for something the server deleted. + server.refuse.set(created.n.uid, { type: 'serverFail' }); + db.run("UPDATE sync_state SET state = 'bad state!' WHERE type = 'ContactCard'"); + await engine.requestSync(); + expect(await search(engine, 'offline')).toEqual(['Offline']); + server.refuse.clear(); + await engine.requestSync(); + expect(await search(engine, 'offline')).toEqual(['Offline']); + expect([...server.account().cards.values()].filter((c) => (c.name as { full: string }).full === 'Offline')).toHaveLength(1); + }); +}); + +describe('contact answers', () => { + it('says so instead of answering empty when nothing was synced yet', async () => { + const engine = engineFor(new FakeContacts()); + const response = await engine.answer([['ContactCard/query', { accountId: JMAP_ACCOUNT, limit: 50 }, '0'], ['AddressBook/get', { accountId: JMAP_ACCOUNT }, '1']]); + expect(response).toBeNull(); + }); + + it('gives the last server state, never a made-up one, and asked-for properties only', async () => { + const server = new FakeContacts(); + const card = server.addCard(); + const { engine } = await synced(server); + const got = await call(engine, 'ContactCard/get', { ids: [card.id, 'nope'], properties: ['uid'] }); + expect(got.state).toBe(server.account().coll.ContactCard.state()); + expect(got.list).toEqual([{ id: card.id, uid: card.uid }]); + expect(got.notFound).toEqual(['nope']); + const books = await call(engine, 'AddressBook/get', { properties: ['name'] }); + expect(books.state).toBe(server.account().coll.AddressBook.state()); + expect(books.list).toEqual([{ id: 'book1', name: 'book1' }]); + }); + + it('pages a query with total and filters by address book', async () => { + const server = new FakeContacts(); + server.addBook({ id: 'book2', name: 'Work' }); + for (let i = 0; i < 5; i += 1) server.addCard({ addressBookIds: { book1: true } }); + server.addCard({ addressBookIds: { book2: true } }); + const { engine } = await synced(server); + const page = await call(engine, 'ContactCard/query', { position: 2, limit: 2, calculateTotal: true }); + expect(page.ids).toHaveLength(2); + expect(page.position).toBe(2); + expect(page.total).toBe(6); + const work = await call(engine, 'ContactCard/query', { filter: { inAddressBook: 'book2' } }); + expect(work.ids).toHaveLength(1); + }); + + it('refuses a filter it cannot read, for that call only', async () => { + const server = new FakeContacts(); + server.addCard(); + const { engine } = await synced(server); + const response = await engine.answer([ + ['ContactCard/query', { accountId: JMAP_ACCOUNT, filter: { phone: '123' } }, '0'], + ['AddressBook/get', { accountId: JMAP_ACCOUNT }, '1'], + ]); + expect(response?.methodResponses[0][0]).toBe('error'); + expect(response?.methodResponses[0][1].type).toBe('unsupportedFilter'); + expect(response?.methodResponses[1][0]).toBe('AddressBook/get'); + }); +}); + +describe('autocomplete', () => { + async function people() { + const server = new FakeContacts(); + server.addCard({ + name: { full: 'Jörg Müller-Lüdenscheidt', components: [{ kind: 'given', value: 'Jörg' }, { kind: 'surname', value: 'Müller-Lüdenscheidt' }] }, + emails: { e: { address: 'joerg.mueller@example.org' } }, + organizations: { o: { name: 'Bäckerei Straße', units: [{ name: 'Backstube' }] } }, + }); + server.addCard({ name: { full: 'Johannes Weiß' }, emails: { e: { address: 'jw@firma.example' } } }); + server.addCard({ name: { full: 'José Ñandú' }, nicknames: { n: { name: 'Pepe' } } }); + server.addCard({ name: { components: [{ kind: 'given', value: 'Hartmann' }] }, emails: { e: { address: 'h@x.example' } } }); + server.addCard({ name: { full: 'Käthe Schön' } }); + return synced(server); + } + + it.each([ + ['Müller', 'Jörg Müller-Lüdenscheidt'], + ['müll', 'Jörg Müller-Lüdenscheidt'], + ['MÜLLER', 'Jörg Müller-Lüdenscheidt'], + ['muller', 'Jörg Müller-Lüdenscheidt'], + ['mueller', 'Jörg Müller-Lüdenscheidt'], + ['joerg', 'Jörg Müller-Lüdenscheidt'], + ['lüden', 'Jörg Müller-Lüdenscheidt'], + ['bäck', 'Jörg Müller-Lüdenscheidt'], + ['strasse', 'Jörg Müller-Lüdenscheidt'], + ['Straße', 'Jörg Müller-Lüdenscheidt'], + ['backstube', 'Jörg Müller-Lüdenscheidt'], + ['schoen', 'Käthe Schön'], + ['kaethe', 'Käthe Schön'], + ['schon', 'Käthe Schön'], + ['SCHÖN', 'Käthe Schön'], + ['hannes', 'Johannes Weiß'], + ['weiss', 'Johannes Weiß'], + ['firma', 'Johannes Weiß'], + ['jw@firma', 'Johannes Weiß'], + ['jose', 'José Ñandú'], + ['nandu', 'José Ñandú'], + ['pepe', 'José Ñandú'], + ['jö mül', 'Jörg Müller-Lüdenscheidt'], + ['"Jörg Müller"', 'Jörg Müller-Lüdenscheidt'], + ['jö', null], + ['jo*', null], + ])('finds %s', async (query, expected) => { + const { engine } = await people(); + const found = await search(engine, query); + if (expected) expect(found).toEqual([expected]); + else expect(found.length).toBeGreaterThan(1); + }); + + it('does not match words that only share a start with another card', async () => { + const { engine } = await people(); + expect(await search(engine, 'mül weiß')).toEqual([]); + expect(await search(engine, 'xyz')).toEqual([]); + expect(await search(engine, '*')).toEqual([]); + }); + + it('finds a card by a component when it has no full name', async () => { + const { db, engine } = await people(); + expect(await call(engine, 'ContactCard/query', { filter: { text: 'hart' } })).toMatchObject({ ids: [expect.any(String)] }); + expect(db.get<{ sort_key: string }>("SELECT sort_key FROM contact_card WHERE json LIKE '%Hartmann%'")?.sort_key).toBe('hartmann'); + }); + + it('lists names starting with the text before names containing it, and stops at the limit', async () => { + const server = new FakeContacts(); + for (let i = 0; i < 60; i += 1) server.addCard({ name: { full: `Aanna ${String(i).padStart(2, '0')}` } }); + server.addCard({ name: { full: 'Zoe Anna' } }); + const { engine } = await synced(server); + const found = await search(engine, 'anna'); + expect(found).toHaveLength(50); + expect(found[0]).toBe('Zoe Anna'); + }); + + it('keeps accounts apart', async () => { + const server = new FakeContacts(); + server.addCard({ name: { full: 'Own Person' } }); + server.addBook({ id: 'teambook', name: 'Team' }, SHARED_ACCOUNT); + server.addCard({ name: { full: 'Team Person' } }, SHARED_ACCOUNT); + const { engine } = await synced(server); + expect(await search(engine, 'person')).toEqual(['Own Person']); + expect(await search(engine, 'person', SHARED_ACCOUNT)).toEqual(['Team Person']); + }); + + it('finds the new name and not the old after an edit is queued', async () => { + const server = new FakeContacts(); + const card = server.addCard({ name: { full: 'Oldname' } }); + const { engine } = await synced(server); + await call(engine, 'ContactCard/set', { update: { [card.id]: { name: { full: 'Newname' } } } }); + expect(await search(engine, 'newname')).toEqual(['Newname']); + expect(await search(engine, 'oldname')).toEqual([]); + }); +}); + +describe('patches', () => { + it('applies pointers and removes on null, leaving the original alone', () => { + const card = { name: { full: 'A', given: 'B' }, note: 'x' }; + const out = applyPatch(card, { 'name/full': 'C', note: null, 'a~1b': 1 }); + expect(out).toEqual({ name: { full: 'C', given: 'B' }, 'a/b': 1 }); + expect(card.name.full).toBe('A'); + expect(() => applyPatch(card, { 'missing/x': 1 })).toThrow(); + }); + + it('merges: newer wins, a whole property replaces what is below it, a path goes into an older whole value', () => { + expect(mergePatches({ a: 1, b: 2 }, { b: 3 })).toEqual({ a: 1, b: 3 }); + expect(mergePatches({ 'name/full': 'A', 'name/given': 'B' }, { name: { full: 'C' } })).toEqual({ name: { full: 'C' } }); + expect(mergePatches({ name: { full: 'A', given: 'B' } }, { 'name/full': 'C' })).toEqual({ name: { full: 'C', given: 'B' } }); + expect(mergePatches({ note: 'x' }, { note: null })).toEqual({ note: null }); + }); +}); + +describe('queued edits', () => { + it('merges edits per card, and sends one patch after coming back', async () => { + const server = new FakeContacts(); + const card = server.addCard({ note: 'old', name: { full: 'Anna' } }); + const { db, engine } = await synced(server); + server.offline = true; + engine.setOnline(false); + + await call(engine, 'ContactCard/set', { update: { [card.id]: { note: 'first' } } }); + await call(engine, 'ContactCard/set', { update: { [card.id]: { note: 'second', 'name/full': 'Anna B' } } }); + await call(engine, 'ContactCard/set', { update: { [card.id]: { 'name/full': 'Anna C' } } }); + expect(count(db, 'contact_pending')).toBe(1); + expect(await engine.areaCall('contacts.pending', undefined)).toBe(1); + const seen = await call(engine, 'ContactCard/get', { ids: [card.id] }); + expect(seen.list[0]).toMatchObject({ note: 'second', name: { full: 'Anna C' } }); + + server.offline = false; + engine.setOnline(true); + await engine.areaCall('contacts.flush', undefined); + const sets = setCalls(server); + expect(sets).toHaveLength(1); + expect(sets[0].update[card.id]).toEqual({ note: 'second', 'name/full': 'Anna C' }); + expect(server.account().cards.get(card.id)).toMatchObject({ note: 'second', name: { full: 'Anna C' } }); + expect(count(db, 'contact_pending')).toBe(0); + }); + + it('lets stand-in ids work in later edits and sends one create', async () => { + const server = new FakeContacts(); + const { db, engine } = await synced(server); + engine.setOnline(false); + const { created } = await call(engine, 'ContactCard/set', { create: { n: { name: { full: 'Neu' }, addressBookIds: { book1: true } } } }); + const standIn = created.n.id; + expect(created.n.uid).toMatch(/^urn:uuid:/); + + const fetched = await call(engine, 'ContactCard/get', { ids: [standIn] }); + expect(fetched.list[0]).toMatchObject({ id: standIn, name: { full: 'Neu' }, uid: created.n.uid }); + const updated = await call(engine, 'ContactCard/set', { update: { [standIn]: { note: 'added' } } }); + expect(updated.updated).toEqual({ [standIn]: null }); + + engine.setOnline(true); + await engine.requestSync(); + const sets = setCalls(server); + expect(sets).toHaveLength(1); + const sent = Object.values(sets[0].create)[0] as Record; + expect(sent).toMatchObject({ name: { full: 'Neu' }, note: 'added', uid: created.n.uid }); + expect(sent).not.toHaveProperty('id'); + const onServer = [...server.account().cards.values()]; + expect(onServer).toHaveLength(1); + + // The page may still hold the stand-in id: it finds the card, and can edit it. + const late = await call(engine, 'ContactCard/get', { ids: [standIn] }); + expect(late.list[0].id).toBe(onServer[0].id); + expect(db.get('SELECT 1 AS x FROM contact_card WHERE id = ?', [standIn])).toBeUndefined(); + expect(count(db, 'contact_card')).toBe(1); + engine.setOnline(false); + await call(engine, 'ContactCard/set', { update: { [standIn]: { note: 'later' } } }); + engine.setOnline(true); + await engine.requestSync(); + expect(server.account().cards.get(onServer[0].id)).toMatchObject({ note: 'later' }); + }); + + it('never sends a card created and deleted while offline', async () => { + const server = new FakeContacts(); + const { db, engine } = await synced(server); + engine.setOnline(false); + const { created } = await call(engine, 'ContactCard/set', { create: { n: { name: { full: 'Short' }, addressBookIds: { book1: true } } } }); + const gone = await call(engine, 'ContactCard/set', { destroy: [created.n.id] }); + expect(gone.destroyed).toEqual([created.n.id]); + engine.setOnline(true); + await engine.requestSync(); + expect(setCalls(server)).toHaveLength(0); + expect(count(db, 'contact_card')).toBe(0); + }); + + it('hides a queued delete, also from sync, then deletes on the server', async () => { + const server = new FakeContacts(); + const card = server.addCard(); + const { db, engine } = await synced(server); + engine.setOnline(false); + await call(engine, 'ContactCard/set', { destroy: [card.id] }); + expect((await call(engine, 'ContactCard/get', { ids: [card.id] })).notFound).toEqual([card.id]); + const again = await call(engine, 'ContactCard/set', { destroy: [card.id], update: { [card.id]: { note: 'x' } } }); + expect(again.notDestroyed[card.id].type).toBe('notFound'); + expect(again.notUpdated[card.id].type).toBe('notFound'); + engine.setOnline(true); + await engine.requestSync(); + expect(server.account().cards.has(card.id)).toBe(false); + expect(count(db, 'contact_card')).toBe(0); + }); + + it('keeps a queued delete hidden when a full sync brings the card again', async () => { + const server = new FakeContacts(); + const card = server.addCard({ name: { full: 'Hidden' } }); + const { db, engine } = await synced(server); + await call(engine, 'ContactCard/set', { destroy: [card.id] }); + server.refuse.set(card.id, { type: 'serverFail' }); + db.run("UPDATE sync_state SET state = 'bad state!' WHERE type = 'ContactCard'"); + await engine.requestSync(); + expect(await engine.areaCall('contacts.pending', undefined)).toBe(1); + expect(await search(engine, 'hidden')).toEqual([]); + server.refuse.clear(); + await engine.requestSync(); + expect(server.account().cards.has(card.id)).toBe(false); + }); + + it('keeps the queued edit on top when sync brings a newer card', async () => { + const server = new FakeContacts(); + const card = server.addCard({ note: 'old', name: { full: 'Anna' } }); + const { engine } = await synced(server); + await call(engine, 'ContactCard/set', { update: { [card.id]: { note: 'mine' } } }); + // A pass with the flush step still to come: the row was already there, so the merge shows on the delta. + server.refuse.set(card.id, { type: 'serverFail' }); + server.editCard(card.id, { name: { full: 'Anna Server' } }); + await engine.requestSync(); + const seen = await call(engine, 'ContactCard/get', { ids: [card.id] }); + expect(seen.list[0]).toMatchObject({ note: 'mine', name: { full: 'Anna Server' } }); + expect(await engine.areaCall('contacts.pending', undefined)).toBe(1); + server.refuse.clear(); + await engine.requestSync(); + expect(server.account().cards.get(card.id)).toMatchObject({ note: 'mine', name: { full: 'Anna Server' } }); + expect(await engine.areaCall('contacts.conflicts', undefined)).toEqual([]); + }); + + it('refuses a held upload as a photo, the way the client takes as a blob refusal', async () => { + const server = new FakeContacts(); + const card = server.addCard(); + const { engine } = await synced(server); + const result = await call(engine, 'ContactCard/set', { update: { [card.id]: { media: { p: { kind: 'photo', blobId: 'bulwark-local-123' } } } } }); + expect(result.notUpdated[card.id]).toMatchObject({ type: 'invalidProperties', properties: ['media'] }); + expect(await engine.areaCall('contacts.pending', undefined)).toBe(0); + }); + + it('refuses a card with no address book, since it could never be saved', async () => { + const server = new FakeContacts(); + const { engine } = await synced(server); + const result = await call(engine, 'ContactCard/set', { create: { n: { name: { full: 'Nowhere' } } } }); + expect(result.notCreated.n).toMatchObject({ type: 'invalidProperties', properties: ['addressBookIds'] }); + expect(await engine.areaCall('contacts.pending', undefined)).toBe(0); + }); + + it('answers a patch that leads nowhere per record', async () => { + const server = new FakeContacts(); + const card = server.addCard(); + const { engine } = await synced(server); + const result = await call(engine, 'ContactCard/set', { update: { [card.id]: { 'nope/x': 1 } } }); + expect(result.notUpdated[card.id].type).toBe('invalidPatch'); + }); +}); + +describe('lost responses', () => { + it('finds the card by uid instead of creating it twice', async () => { + const server = new FakeContacts(); + // Without the refusal Stalwart gives, only the uid lookup can stop a second card. + server.allowDuplicateUids = true; + const { db, engine } = await synced(server); + const { created } = await call(engine, 'ContactCard/set', { create: { n: { name: { full: 'Once' }, addressBookIds: { book1: true } } } }); + + server.loseNextSet = true; + await engine.requestSync(); + expect(server.account().cards.size).toBe(1); + expect(await engine.areaCall('contacts.pending', undefined)).toBe(1); + + await engine.requestSync(); + expect(server.account().cards.size).toBe(1); + expect(setCalls(server)).toHaveLength(1); + expect(await engine.areaCall('contacts.pending', undefined)).toBe(0); + const real = [...server.account().cards.keys()][0]; + expect(db.all('SELECT id FROM contact_card').map((r) => r.id)).toEqual([real]); + expect((await call(engine, 'ContactCard/get', { ids: [created.n.id] })).list[0].id).toBe(real); + }); + + it('takes the server refusing the uid as the card being there', async () => { + const server = new FakeContacts(); + const { engine } = await synced(server); + const { created } = await call(engine, 'ContactCard/set', { create: { n: { name: { full: 'Once' }, addressBookIds: { book1: true } } } }); + // The card reached the server by another way than this flush saw. + server.addCard({ uid: created.n.uid, name: { full: 'Once' } }); + await engine.requestSync(); + expect(server.account().cards.size).toBe(1); + expect(await engine.areaCall('contacts.pending', undefined)).toBe(0); + expect(await engine.areaCall('contacts.conflicts', undefined)).toEqual([]); + expect((await call(engine, 'ContactCard/query', {})).ids).toHaveLength(1); + }); + + it('does not take its own lost update for a change on the server', async () => { + const server = new FakeContacts(); + const card = server.addCard({ note: 'old' }); + const { engine } = await synced(server); + await call(engine, 'ContactCard/set', { update: { [card.id]: { note: 'mine' } } }); + server.loseNextSet = true; + await engine.requestSync(); + expect(server.account().cards.get(card.id)).toMatchObject({ note: 'mine' }); + await engine.requestSync(); + expect(setCalls(server)).toHaveLength(1); + expect(await engine.areaCall('contacts.conflicts', undefined)).toEqual([]); + expect(await engine.areaCall('contacts.pending', undefined)).toBe(0); + }); + + it('keeps everything queued when the network fails, and a delete after a lost create still deletes', async () => { + const server = new FakeContacts(); + const { engine } = await synced(server); + const { created } = await call(engine, 'ContactCard/set', { create: { n: { name: { full: 'Twice' }, addressBookIds: { book1: true } } } }); + server.loseNextSet = true; + await engine.requestSync(); + expect(server.account().cards.size).toBe(1); + await call(engine, 'ContactCard/set', { destroy: [created.n.id] }); + await engine.requestSync(); + expect(server.account().cards.size).toBe(0); + expect(await engine.areaCall('contacts.pending', undefined)).toBe(0); + }); +}); + +describe('conflicts', () => { + it('records a refused create and takes it out of the answers', async () => { + const server = new FakeContacts(); + const { engine } = await synced(server); + const { created } = await call(engine, 'ContactCard/set', { create: { n: { name: { full: 'Refused' }, addressBookIds: { book1: true } } } }); + const uid = created.n.uid; + server.refuse.set(uid, { type: 'forbidden', description: 'No rights here' }); + await engine.requestSync(); + const conflicts = await engine.areaCall('contacts.conflicts', undefined) as Array>; + expect(conflicts).toHaveLength(1); + expect(conflicts[0]).toMatchObject({ kind: 'refused', summary: 'Refused', error: 'forbidden: No rights here', jmap: JMAP_ACCOUNT, local: { op: 'create' } }); + expect(await search(engine, 'refused')).toEqual([]); + expect(await engine.areaCall('contacts.pending', undefined)).toBe(0); + }); + + it('records a refused update and goes back to the server copy', async () => { + const server = new FakeContacts(); + const card = server.addCard({ note: 'server' }); + const { engine } = await synced(server); + await call(engine, 'ContactCard/set', { update: { [card.id]: { note: 'mine' } } }); + server.refuse.set(card.id, { type: 'forbidden' }); + await engine.requestSync(); + const conflicts = await engine.areaCall('contacts.conflicts', undefined) as Array>; + expect(conflicts[0]).toMatchObject({ kind: 'refused', recordId: card.id, local: { op: 'update', payload: { note: 'mine' } } }); + expect((await call(engine, 'ContactCard/get', { ids: [card.id] })).list[0].note).toBe('server'); + await engine.areaCall('contacts.dismissConflict', { id: conflicts[0].id }); + expect(await engine.areaCall('contacts.conflicts', undefined)).toEqual([]); + }); + + it('records an edit to a property the server changed too, and does not overwrite it', async () => { + const server = new FakeContacts(); + const card = server.addCard({ note: 'old' }); + const { engine } = await synced(server); + await call(engine, 'ContactCard/set', { update: { [card.id]: { note: 'mine' } } }); + server.editCard(card.id, { note: 'theirs' }); + await engine.requestSync(); + const conflicts = await engine.areaCall('contacts.conflicts', undefined) as Array>; + expect(conflicts).toHaveLength(1); + expect(conflicts[0]).toMatchObject({ kind: 'changed', local: { payload: { note: 'mine' } }, server: { note: 'theirs' } }); + expect(setCalls(server)).toHaveLength(0); + expect(server.account().cards.get(card.id)?.note).toBe('theirs'); + expect((await call(engine, 'ContactCard/get', { ids: [card.id] })).list[0].note).toBe('theirs'); + }); + + it('sends an edit when the server only changed other properties', async () => { + const server = new FakeContacts(); + const card = server.addCard({ note: 'old', name: { full: 'A' } }); + const { engine } = await synced(server); + await call(engine, 'ContactCard/set', { update: { [card.id]: { note: 'mine' } } }); + server.editCard(card.id, { name: { full: 'B' } }); + await engine.requestSync(); + expect(await engine.areaCall('contacts.conflicts', undefined)).toEqual([]); + expect(server.account().cards.get(card.id)).toMatchObject({ note: 'mine', name: { full: 'B' } }); + }); + + it('records an edit to a card deleted on the server', async () => { + const server = new FakeContacts(); + const card = server.addCard({ name: { full: 'Vanished' } }); + const { engine } = await synced(server); + await call(engine, 'ContactCard/set', { update: { [card.id]: { note: 'mine' } } }); + server.removeCard(card.id); + await engine.requestSync(); + const conflicts = await engine.areaCall('contacts.conflicts', undefined) as Array>; + expect(conflicts).toHaveLength(1); + expect(conflicts[0]).toMatchObject({ kind: 'gone', summary: 'Vanished', recordId: card.id }); + expect(await search(engine, 'vanished')).toEqual([]); + }); + + it('does not delete a card that changed on the server since the delete was queued', async () => { + const server = new FakeContacts(); + const card = server.addCard({ note: 'old', name: { full: 'Keep me' } }); + const { engine } = await synced(server); + await call(engine, 'ContactCard/set', { destroy: [card.id] }); + server.editCard(card.id, { note: 'someone cares' }); + await engine.requestSync(); + expect(server.account().cards.has(card.id)).toBe(true); + expect(await search(engine, 'keep')).toEqual(['Keep me']); + const conflicts = await engine.areaCall('contacts.conflicts', undefined) as Array>; + expect(conflicts[0]).toMatchObject({ kind: 'changed', local: { op: 'destroy' } }); + }); + + it('does not count a delete of a card already gone as a conflict', async () => { + const server = new FakeContacts(); + const card = server.addCard(); + const { engine } = await synced(server); + await call(engine, 'ContactCard/set', { destroy: [card.id] }); + server.removeCard(card.id); + await engine.requestSync(); + expect(await engine.areaCall('contacts.conflicts', undefined)).toEqual([]); + expect(await engine.areaCall('contacts.pending', undefined)).toBe(0); + }); +}); + +describe('edits while sending', () => { + it('keeps an edit made while the create was on its way', async () => { + const server = new FakeContacts(); + const { engine } = await synced(server); + const { created } = await call(engine, 'ContactCard/set', { create: { n: { name: { full: 'Racing' }, note: 'one', addressBookIds: { book1: true } } } }); + let edited = false; + const original = server.request.bind(server); + server.request = async (calls: MethodCall[]) => { + if (!edited && calls.some(([name]) => name === 'ContactCard/set')) { + edited = true; + await call(engine, 'ContactCard/set', { update: { [created.n.id]: { note: 'two' } } }); + } + return original(calls); + }; + await engine.requestSync(); + await engine.requestSync(); + const cards = [...server.account().cards.values()]; + expect(cards).toHaveLength(1); + expect(cards[0].note).toBe('two'); + expect(await engine.areaCall('contacts.pending', undefined)).toBe(0); + }); +}); +/* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/lib/store/__tests__/helpers/fake-contacts.ts b/lib/store/__tests__/helpers/fake-contacts.ts new file mode 100644 index 000000000..e6b408755 --- /dev/null +++ b/lib/store/__tests__/helpers/fake-contacts.ts @@ -0,0 +1,229 @@ +/** + * 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 & { id: string }; + +/** The /changes rules of the base fake, for another collection. */ +export function changesFor(coll: Collection, args: Record): Record { + 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 applyServerPatch(card: Obj, patch: Record): void { + for (const [path, value] of Object.entries(patch)) { + const steps = path.split('/'); + let node = card as Record; + for (const step of steps.slice(0, -1)) node = node[step] as Record; + const last = steps[steps.length - 1]; + if (value === null) delete node[last]; + else node[last] = value; + } +} + +class ContactAccount { + readonly books = new Map(); + readonly cards = new Map(); + readonly coll = { AddressBook: new Collection(), ContactCard: new Collection() }; +} + +export class FakeContacts extends FakeJmap { + private readonly contactAccounts = new Map(); + 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(); + /** A server that lets two cards have one uid, as nothing in the RFC forbids it. */ + allowDuplicateUids = false; + + 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 = {}, 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, 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): Record { + const result: Record = { accountId: args.accountId, oldState: account.coll.ContactCard.state() }; + const created: Record = {}; + const notCreated: Record = {}; + const updated: Record = {}; + const notUpdated: Record = {}; + const destroyed: string[] = []; + const notDestroyed: Record = {}; + for (const [cid, raw] of Object.entries((args.create ?? {}) as Record>)) { + 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++}`; + account.cards.set(id, { '@type': 'Card', version: '1.0', ...raw, id }); + account.coll.ContactCard.push('created', id); + created[cid] = { id }; + } + } + for (const [id, patch] of Object.entries((args.update ?? {}) as Record>)) { + 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, patch); + 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): Record { + 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)[filter.inAddressBook as string]); + const position = Number(args.position ?? 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 > 500) throw Object.assign(new Error('requestTooLarge'), { jmapError: 'requestTooLarge' }); + const chosen = ids ?? [...account.cards.keys()].slice(0, 500); + 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': + return this.contactSet(account, args); + default: + return super.handle(name, args); + } + } + + override async request(calls: MethodCall[], using?: string[]): Promise { + // The real server fails the whole request for a capability it doesn't list. + void using; + const response = await super.request(calls); + 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; + } +} diff --git a/lib/store/__tests__/helpers/fake-jmap.ts b/lib/store/__tests__/helpers/fake-jmap.ts index 9aa917be5..1fd274c04 100644 --- a/lib/store/__tests__/helpers/fake-jmap.ts +++ b/lib/store/__tests__/helpers/fake-jmap.ts @@ -25,7 +25,7 @@ type ChangeKind = 'created' | 'updated' | 'destroyed'; interface Log { kind: ChangeKind; id: string } -class Collection { +export class Collection { log: Log[] = []; /** Below this log position, states are refused as truncated. */ truncatedBelow = 0; @@ -169,7 +169,7 @@ export class FakeJmap implements JmapTransport { return list.map((e) => e.id); } - private handle(name: string, args: Record): Record { + protected handle(name: string, args: Record): Record { switch (name) { case 'Mailbox/get': { const ids = args.ids as string[] | null; @@ -275,7 +275,7 @@ export class FakeJmap implements JmapTransport { return { methodResponses: done }; } - async download(): Promise { + async download(_jmap: string, _blobId: string, _type: string, _name: string): Promise { return new Uint8Array(); } diff --git a/lib/store/area.ts b/lib/store/area.ts index fceed110e..9bd11fd35 100644 --- a/lib/store/area.ts +++ b/lib/store/area.ts @@ -52,6 +52,8 @@ export interface StoreContext { scope: StoreScope; now(): number; emit(event: StoreEvent): void; + /** Starts a sync pass, or one more after the running pass. Resolves when they're done. */ + requestSync(): Promise; } export interface SyncContext extends StoreContext { diff --git a/lib/store/areas.ts b/lib/store/areas.ts index 6bff665ca..c8882f3ee 100644 --- a/lib/store/areas.ts +++ b/lib/store/areas.ts @@ -1,6 +1,7 @@ /** Every part of the local store. New areas go here. */ import type { StoreArea } from './area'; +import { contactsArea } from './contacts'; import { mailArea } from './mail'; -export const STORE_AREAS: readonly StoreArea[] = [mailArea]; +export const STORE_AREAS: readonly StoreArea[] = [mailArea, contactsArea]; diff --git a/lib/store/contacts/index.ts b/lib/store/contacts/index.ts new file mode 100644 index 000000000..01b129a8a --- /dev/null +++ b/lib/store/contacts/index.ts @@ -0,0 +1,27 @@ +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], + 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]); + }, + }, +}; diff --git a/lib/store/contacts/methods.ts b/lib/store/contacts/methods.ts new file mode 100644 index 000000000..f3c885909 --- /dev/null +++ b/lib/store/contacts/methods.ts @@ -0,0 +1,209 @@ +/** 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; + +interface Sql { sql: string; params: SqlValue[] } + +function jmapOf(ctx: MethodContext, args: Record): string { + return (args.accountId as string | undefined) ?? ctx.session.primaryAccounts[CONTACTS_CAPABILITY] ?? ctx.defaultJmap; +} + +/** The last state from the server. Throws when this type was never synced, so an empty answer always means empty. */ +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)) throw new MethodError('serverUnavailable', 'Contacts not kept yet'); + return row.state; +} + +function pick(object: Record, properties: unknown): Record { + if (!Array.isArray(properties)) return object; + const out: Record = { 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>(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> = []; + 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, 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 = {}; + const notCreated: Record = {}; + const updated: Record = {}; + const notUpdated: Record = {}; + const destroyed: string[] = []; + const notDestroyed: Record = {}; + ctx.db.transaction(() => { + for (const [cid, card] of Object.entries((args.create ?? {}) as Record)) { + 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)) { + 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> = { + 'AddressBook/get': addressBookGet, + 'ContactCard/query': contactQuery, + 'ContactCard/get': contactGet, + 'ContactCard/set': contactSet, +}; diff --git a/lib/store/contacts/patch.ts b/lib/store/contacts/patch.ts new file mode 100644 index 000000000..07247b3d9 --- /dev/null +++ b/lib/store/contacts/patch.ts @@ -0,0 +1,88 @@ +/** + * 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; +export type Patch = Record; + +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(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]))]; +} + +/** Deep equality that ignores key order. Absent and null count as the same. */ +export function sameJson(a: unknown, b: unknown): boolean { + if (a === b || (a == null && b == null)) 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; +} + +/** Whether `actual` holds everything in `wanted`. A server may add properties of its own. */ +export function coversJson(wanted: unknown, actual: unknown): boolean { + if (isObject(wanted) && isObject(actual)) { + return Object.keys(wanted).every((key) => coversJson(wanted[key], actual[key])); + } + return sameJson(wanted, actual); +} diff --git a/lib/store/contacts/pending.ts b/lib/store/contacts/pending.ts new file mode 100644 index 000000000..aaadc64b7 --- /dev/null +++ b/lib/store/contacts/pending.ts @@ -0,0 +1,459 @@ +/** + * 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, which ends up the same way. + */ + +import type { StoreContext, SyncContext } from '../area'; +import { responseFor, 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; +const SET_BATCH_BYTES = 5 * 1024 * 1024; +const GET_BATCH = 500; +const CALLS_PER_REQUEST = 16; +/** 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; + 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('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 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 = { ...card, uid }; + savePending(ctx.db, ctx, { jmap, id, op: 'create', uid, payload: JSON.stringify(payload), base: null }); + writeCard(ctx.db, jmap, { '@type': 'Card', version: '1.0', ...payload, 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 = applyPatch(JSON.parse(pending.payload as string) as JsonObject, patch); + savePending(ctx.db, ctx, { jmap, id, op: 'create', uid: pending.uid, payload: JSON.stringify(payload), base: null }); + } else { + const merged = pending ? mergePatches(JSON.parse(pending.payload as string) as Patch, patch) : patch; + savePending(ctx.db, ctx, { jmap, id, op: 'update', payload: JSON.stringify(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[]): Promise> { + const cards = new Map(); + for (let i = 0; i < ids.length; i += GET_BATCH) { + const response = await ctx.transport.request([['ContactCard/get', { accountId: jmap, ids: ids.slice(i, i + GET_BATCH) }, 'g']], CONTACTS_USING); + for (const card of responseFor(response, 'g')[1].list as Card[]) cards.set(card.id, card); + } + return cards; +} + +/** The ids the server has for these uids. */ +async function lookUpUids(ctx: SyncContext, jmap: string, uids: string[]): Promise> { + const found = new Map(); + for (let i = 0; i < uids.length; i += CALLS_PER_REQUEST) { + const group = uids.slice(i, i + CALLS_PER_REQUEST); + 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 ids = responseFor(response, `q${n}`)[1].ids as string[]; + if (ids.length > 0) found.set(uid, ids[0]); + }); + } + return found; +} + +/** 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; update: Map; destroy: Map } + +/** 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, 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) { + settleCreated(ctx, row, { ...(JSON.parse(row.payload as string) as JsonObject), ...uidHit }); + return; + } + out.create.set(id, row); + return; + } + if (row.op === 'destroy' && id.startsWith(STAND_IN_PREFIX)) { + // A create that may have gone out, deleted since. Without a card on the server, nothing to do. + dropPending(db, jmap, id); + if (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); + } else if (row.base && !sameJson(withoutStamps(JSON.parse(row.base) as JsonObject), withoutStamps(server))) { + 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 : null; + 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]))) { + dropPending(db, jmap, id); + writeCard(db, jmap, server); + return; + } + if (!applied || (base && touched.some((key) => !sameJson(base[key], server[key])))) { + 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): void { + const now = pendingFor(ctx.db, sent.jmap, sent.id); + if (!now) return; + if (now.op === 'update' && now.payload === sent.payload) { + dropPending(ctx.db, sent.jmap, sent.id); + return; + } + // Edited again while this was on its way: that edit now builds on what was sent. + const base = applyPatch(server, JSON.parse(sent.payload as string) as Patch); + ctx.db.run('UPDATE contact_pending SET base = ? WHERE seq = ?', [JSON.stringify(base), now.seq]); +} + +function settleRefused(ctx: SyncContext, row: PendingRow, error: SetError, server: Card | undefined): void { + const { db } = ctx; + if (TRANSIENT_ERRORS.has(error.type)) return; + if (row.op === 'destroy' && error.type === 'notFound') { + dropPending(db, row.jmap, row.id); + return; + } + const kind = error.type === 'notFound' ? 'gone' : 'refused'; + recordConflict(ctx, row, kind, server ?? null, error); + dropPending(db, row.jmap, row.id); + if (server && kind === 'refused') writeCard(db, row.jmap, server); + else removeCard(db, row.jmap, row.id); +} + +/** Takes rows up to about the size of one request. */ +function nextBatch(db: StorageAdapter, jmap: string, afterSeq: number): PendingRow[] { + const rows = db.all('SELECT * FROM contact_pending WHERE jmap = ? AND seq > ? ORDER BY seq LIMIT ?', [jmap, afterSeq, SET_BATCH]); + let bytes = 0; + const batch: PendingRow[] = []; + for (const row of rows) { + bytes += row.payload?.length ?? 0; + if (batch.length > 0 && bytes > SET_BATCH_BYTES) break; + batch.push(row); + } + return batch; +} + +/** 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 { + const { db } = ctx; + let afterSeq = 0; + let settled = false; + for (;;) { + if (ctx.stopped()) break; + const batch = nextBatch(db, jmap, afterSeq); + if (batch.length === 0) break; + afterSeq = batch[batch.length - 1].seq; + settled = true; + + const current = await readServerCards(ctx, jmap, batch.filter((row) => row.op !== 'create' && !row.id.startsWith(STAND_IN_PREFIX)).map((row) => row.id)); + const attempted = batch.filter((row) => row.uid && row.attempted).map((row) => row.uid as string); + const uidIds = await lookUpUids(ctx, jmap, attempted); + const uidCards = await readServerCards(ctx, jmap, [...uidIds.values()]); + const serverByUid = new Map(); + for (const [uid, id] of uidIds) { + const card = uidCards.get(id); + if (card) serverByUid.set(uid, card); + } + + const out: Decision = { create: new Map(), update: new Map(), destroy: new Map() }; + db.transaction(() => { + for (const { seq, id } of batch) { + // 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()) db.run('UPDATE contact_pending SET attempted = 1 WHERE seq = ?', [row.seq]); + }); + if (out.create.size + out.update.size + out.destroy.size === 0) continue; + + 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'); + + const refusedUid: Array<[PendingRow, SetError]> = []; + db.transaction(() => { + for (const [cid, row] of creationIds) { + const created = result.created?.[cid] as JsonObject | undefined; + const refused = result.notCreated?.[cid] as SetError | undefined; + if (created) { + settleCreated(ctx, row, { ...(JSON.parse(row.payload as string) as JsonObject), ...created, id: String(created.id) }); + } else if (refused?.properties?.includes('uid')) { + refusedUid.push([row, refused]); + } 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 (result.updated && id in result.updated) settleUpdated(ctx, row, current.get(id) as Card); + } + 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 has a card with this uid already: this one, sent before. + if (refusedUid.length > 0) { + const ids = await lookUpUids(ctx, jmap, refusedUid.map(([row]) => row.uid as string)); + const cards = await readServerCards(ctx, jmap, [...ids.values()]); + db.transaction(() => { + for (const [row, refused] of refusedUid) { + const id = ids.get(row.uid as string); + const card = id ? cards.get(id) : undefined; + if (card) settleCreated(ctx, row, { ...(JSON.parse(row.payload as string) as JsonObject), ...card }); + else settleRefused(ctx, row, refused, undefined); + } + }); + } + } + if (settled) ctx.emit({ type: 'changed', account: ctx.account, collections: ['ContactCard'] }); +} diff --git a/lib/store/contacts/records.ts b/lib/store/contacts/records.ts new file mode 100644 index 000000000..07dc2f73b --- /dev/null +++ b/lib/store/contacts/records.ts @@ -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 & { id: string }; + +function values(map: unknown): Array> { + if (typeof map !== 'object' || map === null) return []; + return Object.values(map).filter((v): v is Record => 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 { + 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): { 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[] { + 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; +} diff --git a/lib/store/contacts/schema.ts b/lib/store/contacts/schema.ts new file mode 100644 index 000000000..66b30832c --- /dev/null +++ b/lib/store/contacts/schema.ts @@ -0,0 +1,75 @@ +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, + 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 + ); + `, + }, +]; diff --git a/lib/store/contacts/sync.ts b/lib/store/contacts/sync.ts new file mode 100644 index 000000000..f734263d6 --- /dev/null +++ b/lib/store/contacts/sync.ts @@ -0,0 +1,179 @@ +/** + * 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. + */ + +import type { SyncContext, Syncer } from '../area'; +import { runChangesLoop } from '../engine/changes'; +import { deleteObject, putObject } from '../engine/core-schema'; +import { responseFor, type MethodCall } from '../engine/transport'; +import { flushPending, serverDestroyed, storeServerCard } from './pending'; +import { ADDRESS_BOOK_PROPERTIES, CONTACTS_CAPABILITY, CONTACTS_USING, type Card } from './records'; + +const PAGE = 500; +const PAIRS_PER_REQUEST = 4; + +// ---- Address books ---- + +async function fullBooks(ctx: SyncContext, jmap: string): Promise { + 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 { + 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 }> = []; + for (let i = 0; i < ids.length; i += PAGE) { + const response = await ctx.transport.request([['AddressBook/get', { accountId: jmap, ids: ids.slice(i, i + PAGE), 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; gen: number; captured: string } + +async function startFullList(ctx: SyncContext, jmap: string): Promise { + 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, gen, captured }; + ctx.db.transaction(() => ctx.setSyncState(jmap, 'ContactCard', { state: null, phase: 'list', 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 { + const cursor = { ...start }; + for (;;) { + if (ctx.stopped()) return false; + const calls: MethodCall[] = []; + for (let i = 0; i < PAIRS_PER_REQUEST; i += 1) { + calls.push(['ContactCard/query', { accountId: jmap, position: cursor.position + i * PAGE, 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 short = false; + const cards: Card[] = []; + for (let i = 0; i < PAIRS_PER_REQUEST && !short; i += 1) { + const [, query] = responseFor(response, `q${i}`); + if (i === 0 && typeof query.total === 'number') total = query.total; + cards.push(...responseFor(response, `g${i}`)[1].list as Card[]); + got += (query.ids as string[]).length; + short = (query.ids as string[]).length < PAGE; + } + cursor.position += got; + const done = got === 0 || (total === null ? short : cursor.position >= 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[] = []; + for (let i = 0; i < ids.length; i += PAGE) { + const response = await ctx.transport.request([['ContactCard/get', { accountId: jmap, ids: ids.slice(i, i + PAGE) }, '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 { + const row = ctx.syncState(jmap, 'ContactCard'); + 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, '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) { + await flushPending(ctx, jmap); + if (ctx.stopped()) return; + await syncBooks(ctx, jmap); + if (ctx.stopped()) return; + await syncCards(ctx, jmap); + }, +}; diff --git a/lib/store/engine/account-engine.ts b/lib/store/engine/account-engine.ts index 6281afc71..a3d56298e 100644 --- a/lib/store/engine/account-engine.ts +++ b/lib/store/engine/account-engine.ts @@ -196,6 +196,7 @@ export class AccountEngine { transport: this.transport, now: this.now, emit: this.emitter, + requestSync: () => this.requestSync(), stopped: () => this.closed || !this.online, syncState: (jmap, type) => this.syncState(jmap, type), setSyncState: (jmap, type, row) => this.setSyncState(jmap, type, row), @@ -210,6 +211,7 @@ export class AccountEngine { scope: this.scope, now: this.now, emit: this.emitter, + requestSync: () => this.requestSync(), }; } From 7bb8447743db33c21b59d6cda97806a2a2930bbb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:47:00 +0100 Subject: [PATCH 02/35] fix: keep the contact list when the address book can't be read --- lib/jmap/client.ts | 27 ++++- lib/store/__tests__/client-contacts.test.ts | 113 ++++++++++++++++++++ stores/__tests__/contact-store.test.ts | 34 +++++- stores/contact-store.ts | 5 +- 4 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 lib/store/__tests__/client-contacts.test.ts diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 6d35dcdfa..56258dd86 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -7327,7 +7327,10 @@ export class JMAPClient implements IJMAPClient { } } + /** Throws when no account could answer, so a failure doesn't read as having no address books. */ async getAllAddressBooks(): Promise { + let answered = 0; + let failure: unknown; try { const allBooks: AddressBook[] = []; const primaryId = this.getContactsAccountId(); @@ -7353,17 +7356,23 @@ export class JMAPClient implements IJMAPClient { isShared: !isPrimary, })); allBooks.push(...books); + answered += 1; + } 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); + failure ??= error; } } - return allBooks; + if (answered > 0 || !failure) return allBooks; } catch (error) { console.error('Failed to fetch all address books:', error); return this.getAddressBooks(); } + throw failure; } async createAddressBook(name: string): Promise { @@ -8230,6 +8239,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 || []; @@ -8252,8 +8263,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)); } } @@ -8273,7 +8286,10 @@ export class JMAPClient implements IJMAPClient { } } + /** Throws when no account could answer, so a failure doesn't read as an empty address book. */ async getAllContacts(): Promise { + let answered = 0; + let failure: unknown; try { const allContacts: ContactCard[] = []; const primaryId = this.getContactsAccountId(); @@ -8297,16 +8313,19 @@ export class JMAPClient implements IJMAPClient { isShared: !isPrimary, })); allContacts.push(...contacts); + answered += 1; } catch (error) { console.error(`Failed to fetch contacts for account ${accountId}:`, error); + failure ??= error; } } - return allContacts; + if (answered > 0 || !failure) return allContacts; } catch (error) { console.error('Failed to fetch all contacts:', error); return this.getContacts(); } + throw failure; } async getContact(contactId: string, accountId?: string): Promise { diff --git a/lib/store/__tests__/client-contacts.test.ts b/lib/store/__tests__/client-contacts.test.ts new file mode 100644 index 000000000..87d516a34 --- /dev/null +++ b/lib/store/__tests__/client-contacts.test.ts @@ -0,0 +1,113 @@ +// @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 { 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 = { + 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 = { + 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('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); + }); +}); diff --git a/stores/__tests__/contact-store.test.ts b/stores/__tests__/contact-store.test.ts index 1a6fb072d..f9dfabcca 100644 --- a/stores/__tests__/contact-store.test.ts +++ b/stores/__tests__/contact-store.test.ts @@ -9,7 +9,7 @@ import { normalizeContactPhotoUri, TRUSTED_SENDERS_BOOK_NAME, } from '../contact-store'; -import type { ContactCard, ContactMedia } from '@/lib/jmap/types'; +import type { AddressBook, ContactCard, ContactMedia } from '@/lib/jmap/types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { clearContactPhotoCache } from '@/lib/contact-photo-cache'; import { PrincipalsRequestError } from '@/lib/jmap/principals-error'; @@ -1364,6 +1364,38 @@ describe('contact-store', () => { }); }); + describe('an account that cannot answer', () => { + it('keeps its contacts and books while the other accounts refresh', async () => { + const failing = { + getAllContacts: vi.fn().mockRejectedValue(new Error('offline')), + getAllAddressBooks: vi.fn().mockRejectedValue(new Error('offline')), + } as unknown as IJMAPClient; + const working = { + getAllContacts: vi.fn().mockResolvedValue([makeContact({ id: 'b1' })]), + getAllAddressBooks: vi.fn().mockResolvedValue([{ id: 'bookb', name: 'B' }]), + } as unknown as IJMAPClient; + const kept = makeContact({ id: 'acc-a::a1', localAccountId: 'acc-a' }); + const keptBook = { id: 'acc-a::booka', name: 'A', localAccountId: 'acc-a' } as AddressBook; + useContactStore.setState({ contacts: [kept], addressBooks: [keptBook] }); + const accounts = [{ client: failing, localAccountId: 'acc-a' }, { client: working, localAccountId: 'acc-b' }]; + + await useContactStore.getState().fetchAllAccountsContacts(accounts); + await useContactStore.getState().fetchAllAccountsAddressBooks(accounts); + + const state = useContactStore.getState(); + expect(state.contacts.map((c) => c.id)).toEqual(['acc-a::a1', 'acc-b::b1']); + expect(state.addressBooks.map((b) => b.id)).toEqual(['acc-a::booka', 'acc-b::bookb']); + }); + + it('keeps the list when the only account fails', async () => { + const failing = { getAllContacts: vi.fn().mockRejectedValue(new Error('offline')) } as unknown as IJMAPClient; + useContactStore.setState({ contacts: [makeContact()] }); + await useContactStore.getState().fetchContacts(failing); + expect(useContactStore.getState().contacts).toHaveLength(1); + expect(useContactStore.getState().error).toBe('Failed to fetch contacts'); + }); + }); + describe('persistence/partialize', () => { it('should persist contacts when supportsSync is false', () => { const { partialize } = (useContactStore as unknown as { persist: { getOptions: () => { partialize: (state: Record) => Record } } }).persist.getOptions(); diff --git a/stores/contact-store.ts b/stores/contact-store.ts index cd10a91f7..3403088b2 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -671,7 +671,8 @@ export const useContactStore = create()( return prefixContactsWithLocalAccount(list, localAccountId); } catch (error) { debug.error(`Failed to fetch contacts for account ${localAccountId}:`, error); - return [] as ContactCard[]; + // Keep what this account's cards were: it just couldn't answer. + return get().contacts.filter((contact) => contact.localAccountId === localAccountId); } }), ); @@ -694,7 +695,7 @@ export const useContactStore = create()( return prefixAddressBooksWithLocalAccount(list, localAccountId); } catch (error) { debug.error(`Failed to fetch address books for account ${localAccountId}:`, error); - return [] as AddressBook[]; + return get().addressBooks.filter((book) => book.localAccountId === localAccountId); } }), ); From 698510fa08c18e5c15b741a09db6f522c8c316dd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:47:39 +0100 Subject: [PATCH 03/35] feat: list files offline and keep pinned and recently opened files on the device --- lib/store/__tests__/client-files.test.ts | 88 ++++ lib/store/__tests__/files.test.ts | 535 ++++++++++++++++++++++ lib/store/__tests__/helpers/fake-files.ts | 192 ++++++++ lib/store/areas.ts | 3 +- lib/store/files/index.ts | 27 ++ lib/store/files/keep.ts | 295 ++++++++++++ lib/store/files/methods.ts | 108 +++++ lib/store/files/records.ts | 50 ++ lib/store/files/schema.ts | 47 ++ lib/store/files/sync.ts | 160 +++++++ 10 files changed, 1504 insertions(+), 1 deletion(-) create mode 100644 lib/store/__tests__/client-files.test.ts create mode 100644 lib/store/__tests__/files.test.ts create mode 100644 lib/store/__tests__/helpers/fake-files.ts create mode 100644 lib/store/files/index.ts create mode 100644 lib/store/files/keep.ts create mode 100644 lib/store/files/methods.ts create mode 100644 lib/store/files/records.ts create mode 100644 lib/store/files/schema.ts create mode 100644 lib/store/files/sync.ts diff --git a/lib/store/__tests__/client-files.test.ts b/lib/store/__tests__/client-files.test.ts new file mode 100644 index 000000000..08efd48d4 --- /dev/null +++ b/lib/store/__tests__/client-files.test.ts @@ -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 { + 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 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); + }); +}); diff --git a/lib/store/__tests__/files.test.ts b/lib/store/__tests__/files.test.ts new file mode 100644 index 000000000..acf81b483 --- /dev/null +++ b/lib/store/__tests__/files.test.ts @@ -0,0 +1,535 @@ +// @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 = {}): 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 = {}): Promise { + const rig = rigFor(server, scope); + await rig.engine.requestSync(); + return rig; +} + +async function call(engine: AccountEngine, name: string, args: Record = {}): Promise { + 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); + }); + + 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 */ diff --git a/lib/store/__tests__/helpers/fake-files.ts b/lib/store/__tests__/helpers/fake-files.ts new file mode 100644 index 000000000..0ff22f4ed --- /dev/null +++ b/lib/store/__tests__/helpers/fake-files.ts @@ -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(); + readonly coll = new Collection(); +} + +export class FakeFiles extends FakeJmap { + private readonly fileAccounts = new Map(); + private readonly options: Required; + private nextNode = 1; + readonly blobs = new Map(); + /** 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 = {}, 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 { + const isFolder = node.blobId === null; + const full: Record = { 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 = { 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): Record { + 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 { + 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; + } +} diff --git a/lib/store/areas.ts b/lib/store/areas.ts index c8882f3ee..7fb966931 100644 --- a/lib/store/areas.ts +++ b/lib/store/areas.ts @@ -2,6 +2,7 @@ import type { StoreArea } from './area'; import { contactsArea } from './contacts'; +import { filesArea } from './files'; import { mailArea } from './mail'; -export const STORE_AREAS: readonly StoreArea[] = [mailArea, contactsArea]; +export const STORE_AREAS: readonly StoreArea[] = [mailArea, contactsArea, filesArea]; diff --git a/lib/store/files/index.ts b/lib/store/files/index.ts new file mode 100644 index 000000000..0957ced89 --- /dev/null +++ b/lib/store/files/index.ts @@ -0,0 +1,27 @@ +import type { StoreArea } from '../area'; +import { fileBlob, 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], + 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), + }, +}; diff --git a/lib/store/files/keep.ts b/lib/store/files/keep.ts new file mode 100644 index 000000000..a11b93455 --- /dev/null +++ b/lib/store/files/keep.ts @@ -0,0 +1,295 @@ +/** + * 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 size limit for one file doesn't apply. The + * account's total limit (scope.maxTotalBytes, which a policy can lower) does. + * - 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 StoreContext, 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): 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, 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( + `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; +} + +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( + `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 { + 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 && 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 ? 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 ? 'overLimit' : 'tooLarge'); + 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'] }); +} + +// ---- Calls from the page ---- + +function fileRow(db: StorageAdapter, { jmap, id }: NodeRef): NodeRow { + const row = db.get('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( + `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), + }; +} diff --git a/lib/store/files/methods.ts b/lib/store/files/methods.ts new file mode 100644 index 000000000..0945a33eb --- /dev/null +++ b/lib/store/files/methods.ts @@ -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 { + 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, properties: unknown): Record { + if (!Array.isArray(properties)) return node; + const out: Record = { id: node.id }; + for (const property of properties as string[]) if (property in node) out[property] = node[property]; + return out; +} + +const SORTS: Record = { + 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> = []; + 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, 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, 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; + 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> = { + 'FileNode/get': fileNodeGet, + 'FileNode/query': fileNodeQuery, +}; diff --git a/lib/store/files/records.ts b/lib/store/files/records.ts new file mode 100644 index 000000000..fd5cbcb51 --- /dev/null +++ b/lib/store/files/records.ts @@ -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 & { 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 & { '@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]); +} diff --git a/lib/store/files/schema.ts b/lib/store/files/schema.ts new file mode 100644 index 000000000..9a3f20e35 --- /dev/null +++ b/lib/store/files/schema.ts @@ -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) + ); + `, + }, +]; diff --git a/lib/store/files/sync.ts b/lib/store/files/sync.ts new file mode 100644 index 000000000..cc9d99918 --- /dev/null +++ b/lib/store/files/sync.ts @@ -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 { + 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 { + 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 { + 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(); + 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); + }, +}; From 64434bd5a6e7f3e22692ccf5a4c3443c1d016979 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:47:40 +0100 Subject: [PATCH 04/35] fix: keep the file list when refreshing a folder fails --- stores/__tests__/file-store.test.ts | 16 ++++++++++++++++ stores/file-store.ts | 4 +++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/stores/__tests__/file-store.test.ts b/stores/__tests__/file-store.test.ts index 6cb46aab6..bf7080b02 100644 --- a/stores/__tests__/file-store.test.ts +++ b/stores/__tests__/file-store.test.ts @@ -120,6 +120,22 @@ describe('file-store hierarchy (issue #379)', () => { }); }); + it('keeps the listing when refreshing the same folder fails, and starts empty in a new one', async () => { + const client = makeMockClient([dir('stuff', 'Stuff', null), file('notes', 'Notes.md', null), file('inner', 'Inner.md', 'stuff')]); + useFileStore.getState().initClient(client); + await useFileStore.getState().navigate(null); + expect(useFileStore.getState().resources.map(r => r.name).sort()).toEqual(['Notes.md', 'Stuff']); + + client.listAllFileNodes = async () => { throw new Error('offline'); }; + await useFileStore.getState().navigate(null); + expect(useFileStore.getState().error).toBe('offline'); + expect(useFileStore.getState().resources.map(r => r.name).sort()).toEqual(['Notes.md', 'Stuff']); + + await useFileStore.getState().navigate('stuff', 'Stuff'); + expect(useFileStore.getState().error).toBe('offline'); + expect(useFileStore.getState().resources).toEqual([]); + }); + it('lists only root-level nodes at the root, not the whole tree flattened', async () => { // Stuff > Nonsense > Notes.md / Other.md (the exact shape from the issue) const client = makeMockClient([ diff --git a/stores/file-store.ts b/stores/file-store.ts index f9ec1a6b5..3feada71e 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -982,7 +982,9 @@ export const useFileStore = create((set, get) => ({ error: error instanceof Error ? error.message : 'Failed to list directory', hasListed: true, isLoading: false, - resources: [], + // A refresh that fails keeps what the folder showed. Another folder's + // rows would be wrong here, so one being opened starts empty. + resources: opensFolder ? [] : get().resources, }); } }, From 691deff583e4ddf7c39a15dcec516c4f04e3cb58 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:50:58 +0100 Subject: [PATCH 05/35] fix: refuse a queued contact too large to ever send --- lib/store/__tests__/contacts.test.ts | 13 +++++++++++++ lib/store/contacts/pending.ts | 21 ++++++++++++++------- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/lib/store/__tests__/contacts.test.ts b/lib/store/__tests__/contacts.test.ts index 526595a85..2331572e2 100644 --- a/lib/store/__tests__/contacts.test.ts +++ b/lib/store/__tests__/contacts.test.ts @@ -470,6 +470,19 @@ describe('queued edits', () => { expect(await engine.areaCall('contacts.pending', undefined)).toBe(0); }); + it('refuses a card too large to ever send, so it cannot hold up the rest', async () => { + const server = new FakeContacts(); + const card = server.addCard(); + const { engine } = await synced(server); + const photo = { p: { kind: 'photo', uri: `data:image/jpeg;base64,${'A'.repeat(9 * 1024 * 1024)}` } }; + const made = await call(engine, 'ContactCard/set', { create: { n: { name: { full: 'Big' }, addressBookIds: { book1: true }, media: photo } } }); + expect(made.notCreated.n.type).toBe('tooLarge'); + const edited = await call(engine, 'ContactCard/set', { update: { [card.id]: { media: photo } } }); + expect(edited.notUpdated[card.id].type).toBe('tooLarge'); + expect(await engine.areaCall('contacts.pending', undefined)).toBe(0); + expect((await call(engine, 'ContactCard/get', { ids: [card.id] })).list[0]).not.toHaveProperty('media'); + }); + it('answers a patch that leads nowhere per record', async () => { const server = new FakeContacts(); const card = server.addCard(); diff --git a/lib/store/contacts/pending.ts b/lib/store/contacts/pending.ts index aaadc64b7..4cf2feba2 100644 --- a/lib/store/contacts/pending.ts +++ b/lib/store/contacts/pending.ts @@ -27,6 +27,8 @@ const LOCAL_BLOB_MARK = '"blobId":"bulwark-local-'; const SET_BATCH = 100; const SET_BATCH_BYTES = 5 * 1024 * 1024; +/** The server takes 10 MB a request. A bigger card would hold up everything queued behind it, for good. */ +const MAX_RECORD_BYTES = 8 * 1024 * 1024; const GET_BATCH = 500; const CALLS_PER_REQUEST = 16; /** Set errors that say nothing about the edit itself: it goes out again next time. */ @@ -86,6 +88,8 @@ 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', @@ -100,9 +104,10 @@ export function queueCreate(ctx: StoreContext, jmap: string, card: JsonObject): } const id = `${STAND_IN_PREFIX}${crypto.randomUUID()}`; const uid = typeof card.uid === 'string' && card.uid ? card.uid : `urn:uuid:${crypto.randomUUID()}`; - const payload = { ...card, uid }; - savePending(ctx.db, ctx, { jmap, id, op: 'create', uid, payload: JSON.stringify(payload), base: null }); - writeCard(ctx.db, jmap, { '@type': 'Card', version: '1.0', ...payload, id }); + const payload = JSON.stringify({ ...card, uid }); + if (payload.length > MAX_RECORD_BYTES) 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 }; } @@ -121,11 +126,13 @@ export function queueUpdate(ctx: StoreContext, jmap: string, rawId: string, patc throw error; } if (pending?.op === 'create') { - const payload = applyPatch(JSON.parse(pending.payload as string) as JsonObject, patch); - savePending(ctx.db, ctx, { jmap, id, op: 'create', uid: pending.uid, payload: JSON.stringify(payload), base: null }); + const payload = JSON.stringify(applyPatch(JSON.parse(pending.payload as string) as JsonObject, patch)); + if (payload.length > MAX_RECORD_BYTES) return TOO_LARGE_ERROR; + savePending(ctx.db, ctx, { jmap, id, op: 'create', uid: pending.uid, payload, base: null }); } else { - const merged = pending ? mergePatches(JSON.parse(pending.payload as string) as Patch, patch) : patch; - savePending(ctx.db, ctx, { jmap, id, op: 'update', payload: JSON.stringify(merged), base: pending ? pending.base : JSON.stringify(card) }); + const merged = JSON.stringify(pending ? mergePatches(JSON.parse(pending.payload as string) as Patch, patch) : patch); + if (merged.length > MAX_RECORD_BYTES) 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; From 9fbe33004bf71f5f10dcf57e4ca989b6956a4efe Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:20:39 +0100 Subject: [PATCH 06/35] fix: send contact edits that remove entries and stop reading own edits as conflicts An edit that removed an entry was dropped at flush, because a server value with more keys counted as already applied. Other fixes in the same path: - the local copy after a flush is the server's read-back, never what was sent, since Stalwart 1.0 stores a data: URI as a blob - a delete or a newer edit after a lost response no longer reads as a change on the server, and an edit made during a refused send stays - a create only becomes a card with its uid if that card holds what was sent, otherwise it is a refused conflict - a refused or failed flush no longer stops contacts syncing - request limits come from the session - answers wait for the first full pass - paging goes by anchor, so a deleted card doesn't make the next one get skipped and swept contacts.test.ts: "continues a stopped full sync" now checks the anchor instead of position 2000, because the cursor no longer pages by position. --- lib/store/__tests__/contacts.test.ts | 392 ++++++++++++++++++- lib/store/__tests__/helpers/fake-contacts.ts | 51 ++- lib/store/contacts/methods.ts | 8 +- lib/store/contacts/patch.ts | 35 +- lib/store/contacts/pending.ts | 353 +++++++++++------ lib/store/contacts/schema.ts | 2 + lib/store/contacts/sync.ts | 83 ++-- lib/store/engine/limits.ts | 31 ++ 8 files changed, 788 insertions(+), 167 deletions(-) create mode 100644 lib/store/engine/limits.ts diff --git a/lib/store/__tests__/contacts.test.ts b/lib/store/__tests__/contacts.test.ts index 2331572e2..c05334fab 100644 --- a/lib/store/__tests__/contacts.test.ts +++ b/lib/store/__tests__/contacts.test.ts @@ -6,19 +6,19 @@ import path from 'node:path'; import { textFilter } from '@/lib/jmap/search-utils'; import { DEFAULT_SCOPE, type StoreEvent } from '../area'; import { contactsArea } from '../contacts'; -import { applyPatch, mergePatches } from '../contacts/patch'; +import { applyPatch, coversJson, mergePatches, sameJson } from '../contacts/patch'; import { AccountEngine } from '../engine/account-engine'; -import type { MethodCall } from '../engine/transport'; +import type { MethodCall, StoreSession } from '../engine/transport'; import type { StorageAdapter } from '../storage'; import { JMAP_ACCOUNT } from './helpers/fake-jmap'; import { CONTACTS_SESSION, FakeContacts, MAIL_ONLY_ACCOUNT, SHARED_ACCOUNT } from './helpers/fake-contacts'; import { openFileStore, openMemoryStore } from './helpers/node-adapter'; -function engineFor(server: FakeContacts, db: StorageAdapter = openMemoryStore(), emit?: (e: StoreEvent) => void) { +function engineFor(server: FakeContacts, db: StorageAdapter = openMemoryStore(), emit?: (e: StoreEvent) => void, session: StoreSession = CONTACTS_SESSION) { return new AccountEngine({ account: 'a@fake.test', db, - session: CONTACTS_SESSION, + session, transport: server, areas: [contactsArea], scope: DEFAULT_SCOPE, @@ -107,9 +107,11 @@ describe('contacts sync', () => { const second = openFileStore(file); await engineFor(server, second).requestSync(); expect(count(second, 'contact_card')).toBe(2600); - const positions = server.calls.filter(([name]) => name === 'ContactCard/query').map(([, args]) => args.position); - expect(positions[0]).toBe(2000); second.close(); + // It goes on from the last card read, not from the start. + const first_ = server.calls.find(([name]) => name === 'ContactCard/query')?.[1]; + expect(first_?.anchor).toBeTruthy(); + expect(first_?.anchorOffset).toBe(1); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -670,4 +672,382 @@ describe('edits while sending', () => { expect(await engine.areaCall('contacts.pending', undefined)).toBe(0); }); }); + +const mine = (engine: AccountEngine, patch: Record, id: string) => call(engine, 'ContactCard/set', { update: { [id]: patch } }); +const conflictsOf = async (engine: AccountEngine) => await engine.areaCall('contacts.conflicts', undefined) as Array>; +const pendingOf = (engine: AccountEngine) => engine.areaCall('contacts.pending', undefined); + +describe('edits that make a card smaller', () => { + it.each<[string, Record, Record, Record]>([ + ['the last email', { emails: { e1: { address: 'a@example.org' } } }, { emails: {} }, { emails: {} }], + ['one of two emails', { emails: { e1: { address: 'a@example.org' }, e2: { address: 'b@example.org' } } }, { emails: { e1: { address: 'a@example.org' } } }, { emails: { e1: { address: 'a@example.org' } } }], + ['an email by pointer', { emails: { e1: { address: 'a@example.org' }, e2: { address: 'b@example.org' } } }, { 'emails/e2': null }, { emails: { e1: { address: 'a@example.org' } } }], + ['a keyword', { keywords: { work: true, home: true } }, { keywords: { work: true } }, { keywords: { work: true } }], + ['an address book', { addressBookIds: { book1: true, book2: true } }, { addressBookIds: { book2: true } }, { addressBookIds: { book2: true } }], + ['a group member', { kind: 'group', members: { 'urn:uuid:a': true, 'urn:uuid:b': true } }, { members: { 'urn:uuid:a': true } }, { members: { 'urn:uuid:a': true } }], + ])('sends the removal of %s', async (_label, start, patch, expected) => { + const server = new FakeContacts(['book1', 'book2']); + const card = server.addCard(start); + const { engine } = await synced(server); + await mine(engine, patch, card.id); + await engine.requestSync(); + expect(server.account().cards.get(card.id)).toMatchObject(expected); + for (const [key, value] of Object.entries(expected)) expect(server.account().cards.get(card.id)?.[key]).toEqual(value); + expect(await conflictsOf(engine)).toEqual([]); + expect(await pendingOf(engine)).toBe(0); + }); + + it('reads a key only the server has as its own, and one it had before as not yet removed', () => { + const wanted = { e1: { address: 'a' } }; + expect(coversJson(wanted, { e1: { address: 'a' }, added: { address: 'x' } }, { e1: { address: 'a' } })).toBe(true); + expect(coversJson(wanted, { e1: { address: 'a' }, e2: { address: 'b' } }, { e1: { address: 'a' }, e2: { address: 'b' } })).toBe(false); + expect(coversJson({ e: { '@type': 'x' } }, { e: { '@type': 'x', extra: 1 } }, undefined)).toBe(true); + // Stalwart 1.0 keeps a data: URI as a blob. + expect(coversJson({ m: { kind: 'photo', uri: 'data:image/png;base64,AA' } }, { m: { kind: 'photo', blobId: 'b1' } }, undefined)).toBe(true); + expect(coversJson({ m: { kind: 'logo', uri: 'data:image/png;base64,AA' } }, { m: { kind: 'photo', blobId: 'b1' } }, undefined)).toBe(false); + expect(sameJson({}, undefined)).toBe(true); + }); +}); + +describe('flush failures', () => { + it('still syncs after the server refuses the set as a whole, and records the edits', async () => { + const server = new FakeContacts(); + const card = server.addCard({ note: 'server' }); + const { engine } = await synced(server); + await mine(engine, { note: 'mine' }, card.id); + await call(engine, 'ContactCard/set', { create: { n: { name: { full: 'New' }, addressBookIds: { book1: true } } } }); + server.failSet = () => ({ jmapError: 'forbidden' }); + server.addCard({ name: { full: 'From the server' } }); + await engine.requestSync(); + expect(await search(engine, 'from the server')).toEqual(['From the server']); + expect((await conflictsOf(engine)).map((c) => c.kind)).toEqual(['refused', 'refused']); + expect(await pendingOf(engine)).toBe(0); + expect((await call(engine, 'ContactCard/get', { ids: [card.id] })).list[0].note).toBe('server'); + }); + + it('keeps edits and still syncs when the failure says nothing about them', async () => { + const server = new FakeContacts(); + const card = server.addCard({ note: 'server' }); + const { engine } = await synced(server); + await mine(engine, { note: 'mine' }, card.id); + server.failSet = () => ({ jmapError: 'serverFail' }); + server.addCard({ name: { full: 'From the server' } }); + await engine.requestSync(); + expect(await search(engine, 'from the server')).toEqual(['From the server']); + expect(await conflictsOf(engine)).toEqual([]); + expect(await pendingOf(engine)).toBe(1); + server.failSet = null; + await engine.requestSync(); + expect(server.account().cards.get(card.id)?.note).toBe('mine'); + }); + + it('takes the rest of a batch through when one card makes the request fail', async () => { + const server = new FakeContacts(); + const good = server.addCard({ note: 'a' }); + const bad = server.addCard({ note: 'b' }); + const { engine } = await synced(server); + await mine(engine, { note: 'good edit' }, good.id); + await mine(engine, { note: 'poison' }, bad.id); + server.failSet = (args) => (JSON.stringify(args).includes('poison') ? { http: 400 } : null); + await engine.requestSync(); + expect(server.account().cards.get(good.id)?.note).toBe('good edit'); + expect(server.account().cards.get(bad.id)?.note).toBe('b'); + const conflicts = await conflictsOf(engine); + expect(conflicts.map((c) => [c.recordId, c.kind])).toEqual([[bad.id, 'refused']]); + expect(await pendingOf(engine)).toBe(0); + }); + + it('takes the request limit from the session', async () => { + const server = new FakeContacts(); + const card = server.addCard(); + const session = { ...CONTACTS_SESSION, capabilities: { ...CONTACTS_SESSION.capabilities, 'urn:ietf:params:jmap:core': { maxSizeRequest: 1000 } } }; + const db = openMemoryStore(); + const engine = engineFor(server, db, undefined, session); + await engine.requestSync(); + const result = await mine(engine, { note: 'x'.repeat(2000) }, card.id); + expect(result.notUpdated[card.id].type).toBe('tooLarge'); + }); +}); + +describe('a uid another card has', () => { + it('does not turn an offline create into that card', async () => { + const server = new FakeContacts(); + const other = server.addCard({ uid: 'urn:uuid:taken', name: { full: 'Their card' } }); + const { db, engine } = await synced(server); + const { created } = await call(engine, 'ContactCard/set', { create: { n: { uid: 'urn:uuid:taken', name: { full: 'My card' }, addressBookIds: { book1: true } } } }); + await engine.requestSync(); + const conflicts = await conflictsOf(engine); + expect(conflicts).toHaveLength(1); + expect(conflicts[0]).toMatchObject({ kind: 'refused', recordId: created.n.id, summary: 'My card', local: { op: 'create', payload: { name: { full: 'My card' } } } }); + expect((await call(engine, 'ContactCard/get', { ids: [created.n.id] })).notFound).toEqual([created.n.id]); + expect(db.all('SELECT id FROM contact_card').map((r) => r.id)).toEqual([other.id]); + expect(server.account().cards.get(other.id)).toMatchObject({ name: { full: 'Their card' } }); + }); + + it('does not take someone else\'s card for its own after a failed attempt either', async () => { + const server = new FakeContacts(); + const { engine } = await synced(server); + const { created } = await call(engine, 'ContactCard/set', { create: { n: { uid: 'urn:uuid:race', name: { full: 'Mine' }, addressBookIds: { book1: true } } } }); + server.offlineBeforeSet = true; + await engine.requestSync(); + const other = server.addCard({ uid: 'urn:uuid:race', name: { full: 'Theirs' } }); + await engine.requestSync(); + expect((await conflictsOf(engine)).map((c) => [c.recordId, c.kind])).toEqual([[created.n.id, 'refused']]); + expect(server.account().cards.size).toBe(1); + expect(server.account().cards.get(other.id)).toMatchObject({ name: { full: 'Theirs' } }); + }); + + it('does not delete someone else\'s card when the create was deleted again', async () => { + const server = new FakeContacts(); + const { engine } = await synced(server); + const { created } = await call(engine, 'ContactCard/set', { create: { n: { uid: 'urn:uuid:race', name: { full: 'Mine' }, addressBookIds: { book1: true } } } }); + server.offlineBeforeSet = true; + await engine.requestSync(); + await call(engine, 'ContactCard/set', { destroy: [created.n.id] }); + const other = server.addCard({ uid: 'urn:uuid:race', name: { full: 'Theirs' } }); + await engine.requestSync(); + expect(server.account().cards.has(other.id)).toBe(true); + expect(await pendingOf(engine)).toBe(0); + }); +}); + +describe('what the server really stored', () => { + const photo = { p: { kind: 'photo', uri: 'data:image/png;base64,iVBORw0KGgo=', mediaType: 'image/png' } }; + + it('does not read its own stored photo as a change after the network went, on 1.0', async () => { + const server = new FakeContacts(); + server.mediaAsBlobs = true; + const { engine } = await synced(server); + const { created } = await call(engine, 'ContactCard/set', { create: { n: { name: { full: 'Foto' }, addressBookIds: { book1: true }, media: photo } } }); + server.offlineAfterSet = true; + await engine.requestSync(); + expect(server.account().cards.size).toBe(1); + server.offline = false; + await call(engine, 'ContactCard/set', { destroy: [created.n.id] }); + await engine.requestSync(); + expect(await conflictsOf(engine)).toEqual([]); + expect(server.account().cards.size).toBe(0); + }); + + it('adopts its own card by uid though the server keeps the photo as a blob', async () => { + const server = new FakeContacts(); + server.mediaAsBlobs = true; + const { engine } = await synced(server); + await call(engine, 'ContactCard/set', { create: { n: { name: { full: 'Foto' }, addressBookIds: { book1: true }, media: photo } } }); + server.loseNextSet = true; + await engine.requestSync(); + await engine.requestSync(); + expect(await conflictsOf(engine)).toEqual([]); + expect(await pendingOf(engine)).toBe(0); + expect(server.account().cards.size).toBe(1); + }); + + it('keeps the card as the server read it back, not as it was sent, before any delta', async () => { + const server = new FakeContacts(); + server.mediaAsBlobs = true; + const { db, engine } = await synced(server); + await call(engine, 'ContactCard/set', { create: { n: { name: { full: 'Foto' }, addressBookIds: { book1: true }, media: photo } } }); + // The network goes right after the read-back, before the delta of this pass. + server.beforeRequest = () => { + const set = server.calls.findIndex(([name]) => name === 'ContactCard/set'); + if (set >= 0 && server.calls.slice(set).some(([name]) => name === 'ContactCard/get')) server.offline = true; + }; + await engine.requestSync(); + server.beforeRequest = null; + server.offline = false; + const row = db.get<{ json: string }>('SELECT json FROM contact_card'); + expect((JSON.parse(row?.json as string) as { media: Record }).media.p).toHaveProperty('blobId'); + const id = [...server.account().cards.keys()][0]; + await mine(engine, { note: 'after' }, id); + await engine.requestSync(); + expect(await conflictsOf(engine)).toEqual([]); + expect(server.account().cards.get(id)?.note).toBe('after'); + }); + + it('does not take a lost photo update for someone else changing it, on 1.0', async () => { + const server = new FakeContacts(); + server.mediaAsBlobs = true; + const card = server.addCard({ note: 'old' }); + const { engine } = await synced(server); + await mine(engine, { media: photo }, card.id); + server.loseNextSet = true; + await engine.requestSync(); + await engine.requestSync(); + expect(await conflictsOf(engine)).toEqual([]); + expect(await pendingOf(engine)).toBe(0); + }); +}); + +describe('after a lost update', () => { + it('still deletes the card', async () => { + const server = new FakeContacts(); + const card = server.addCard({ note: 'old' }); + const { engine } = await synced(server); + await mine(engine, { note: 'mine' }, card.id); + server.loseNextSet = true; + await engine.requestSync(); + await call(engine, 'ContactCard/set', { destroy: [card.id] }); + await engine.requestSync(); + expect(await conflictsOf(engine)).toEqual([]); + expect(server.account().cards.has(card.id)).toBe(false); + }); + + it('does not delete when someone else changed another property too', async () => { + const server = new FakeContacts(); + const card = server.addCard({ note: 'old', name: { full: 'A' } }); + const { engine } = await synced(server); + await mine(engine, { note: 'mine' }, card.id); + server.loseNextSet = true; + await engine.requestSync(); + await call(engine, 'ContactCard/set', { destroy: [card.id] }); + server.editCard(card.id, { name: { full: 'B' } }); + await engine.requestSync(); + expect(server.account().cards.has(card.id)).toBe(true); + expect((await conflictsOf(engine)).map((c) => c.kind)).toEqual(['changed']); + }); + + it('sends a newer edit of the same property', async () => { + const server = new FakeContacts(); + const card = server.addCard({ note: 'old' }); + const { engine } = await synced(server); + await mine(engine, { note: 'first' }, card.id); + server.loseNextSet = true; + await engine.requestSync(); + await mine(engine, { note: 'second' }, card.id); + await engine.requestSync(); + expect(await conflictsOf(engine)).toEqual([]); + expect(server.account().cards.get(card.id)?.note).toBe('second'); + }); +}); + +describe('a refusal while the page kept editing', () => { + it('keeps the newer edit, and only records a conflict if it is refused too', async () => { + const server = new FakeContacts(); + const card = server.addCard({ note: 'old', name: { full: 'A' } }); + const { engine } = await synced(server); + await mine(engine, { note: 'one' }, card.id); + server.refuse.set(card.id, { type: 'forbidden' }); + const original = server.request.bind(server); + let edited = false; + server.request = async (calls: MethodCall[]) => { + if (!edited && calls.some(([name]) => name === 'ContactCard/set')) { + edited = true; + await mine(engine, { 'name/full': 'B' }, card.id); + } + return original(calls); + }; + await engine.requestSync(); + expect(await pendingOf(engine)).toBe(1); + expect(await conflictsOf(engine)).toEqual([]); + server.refuse.clear(); + await engine.requestSync(); + expect(server.account().cards.get(card.id)).toMatchObject({ note: 'one', name: { full: 'B' } }); + expect(await pendingOf(engine)).toBe(0); + }); +}); + +describe('session limits', () => { + it('takes maxObjectsInGet and maxCallsInRequest from the session', async () => { + const server = new FakeContacts(); + server.maxObjectsInGet = 2; + const cards = [server.addCard(), server.addCard(), server.addCard(), server.addCard(), server.addCard()]; + const session = { ...CONTACTS_SESSION, capabilities: { ...CONTACTS_SESSION.capabilities, 'urn:ietf:params:jmap:core': { maxObjectsInGet: 2, maxCallsInRequest: 4 } } }; + const db = openMemoryStore(); + const events: StoreEvent[] = []; + const engine = engineFor(server, db, (e) => events.push(e), session); + await engine.requestSync(); + expect(events.filter((e) => e.type === 'error')).toEqual([]); + expect(count(db, 'contact_card')).toBe(5); + for (const [name, args] of server.calls) { + if (name === 'ContactCard/query') expect(args.limit).toBeLessThanOrEqual(2); + } + for (const card of cards.slice(0, 3)) await mine(engine, { note: 'x' }, card.id); + await engine.requestSync(); + expect(events.filter((e) => e.type === 'error')).toEqual([]); + expect(await pendingOf(engine)).toBe(0); + expect(cards.slice(0, 3).map((c) => server.account().cards.get(c.id)?.note)).toEqual(['x', 'x', 'x']); + }); +}); + +describe('an interrupted first sync', () => { + it('answers serverUnavailable until the first full pass is done', async () => { + const server = new FakeContacts(); + for (let i = 0; i < 2600; i += 1) server.addCard(); + const db = openMemoryStore(); + const engine = engineFor(server, db); + let requests = 0; + server.beforeRequest = () => { + requests += 1; + if (requests === 4) server.offline = true; + }; + await engine.requestSync(); + expect(count(db, 'contact_card')).toBe(2000); + expect(await engine.answer([['ContactCard/query', { accountId: JMAP_ACCOUNT, limit: 50 }, '0']])).toBeNull(); + expect(await engine.answer([['ContactCard/get', { accountId: JMAP_ACCOUNT, ids: null }, '0']])).toBeNull(); + server.offline = false; + server.beforeRequest = null; + await engine.requestSync(); + expect((await call(engine, 'ContactCard/query', { calculateTotal: true })).total).toBe(2600); + }); + + it('goes on answering from the old copy while a later resync runs', async () => { + const server = new FakeContacts(); + for (let i = 0; i < 2600; i += 1) server.addCard(); + const { db, engine } = await synced(server); + db.run("UPDATE sync_state SET state = 'bad state!' WHERE type = 'ContactCard'"); + let requests = 0; + server.beforeRequest = () => { + requests += 1; + if (requests === 3) server.offline = true; + }; + await engine.requestSync(); + expect((await call(engine, 'ContactCard/query', { calculateTotal: true })).total).toBe(2600); + }); +}); + +describe('paging while cards change', () => { + it('does not skip a card when an earlier one is deleted between requests', async () => { + const server = new FakeContacts(); + const cards = Array.from({ length: 2600 }, () => server.addCard()); + const db = openMemoryStore(); + const engine = engineFor(server, db); + let queries = 0; + const original = server.request.bind(server); + server.request = async (calls: MethodCall[]) => { + if (calls.some(([name]) => name === 'ContactCard/query')) { + queries += 1; + if (queries === 2) server.removeCard(cards[0].id); + } + return original(calls); + }; + await engine.requestSync(); + const kept = new Set(db.all<{ id: string }>('SELECT id FROM contact_card').map((r) => r.id)); + expect(cards.slice(1).filter((c) => !kept.has(c.id))).toEqual([]); + expect(kept.has(cards[0].id)).toBe(false); + const anchored = server.calls.filter(([name, args]) => name === 'ContactCard/query' && args.anchor !== undefined); + expect(anchored.length).toBeGreaterThan(0); + }); + + it('starts over when the card it paged from is gone', async () => { + const server = new FakeContacts(); + const cards = Array.from({ length: 2600 }, () => server.addCard()); + const db = openMemoryStore(); + const engine = engineFor(server, db); + let queries = 0; + const original = server.request.bind(server); + server.request = async (calls: MethodCall[]) => { + if (calls.some(([name]) => name === 'ContactCard/query')) { + queries += 1; + if (queries === 2) server.removeCard(cards[1999].id); + } + return original(calls); + }; + const events: StoreEvent[] = []; + await engine.requestSync(); + expect(events).toEqual([]); + const kept = new Set(db.all<{ id: string }>('SELECT id FROM contact_card').map((r) => r.id)); + expect(kept.size).toBe(2599); + expect(kept.has(cards[1999].id)).toBe(false); + }); +}); /* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/lib/store/__tests__/helpers/fake-contacts.ts b/lib/store/__tests__/helpers/fake-contacts.ts index e6b408755..6cec59cbf 100644 --- a/lib/store/__tests__/helpers/fake-contacts.ts +++ b/lib/store/__tests__/helpers/fake-contacts.ts @@ -37,6 +37,15 @@ export function changesFor(coll: Collection, args: Record): Rec return { accountId: args.accountId, oldState: since, ...coll.changes(since, maxChanges) }; } +function storedMedia(card: Obj): void { + const media = card.media as Record> | 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)[key] = { ...rest, blobId: `stored-${uri.length}-${key}` }; + } +} + function applyServerPatch(card: Obj, patch: Record): void { for (const [path, value] of Object.entries(patch)) { const steps = path.split('/'); @@ -63,6 +72,16 @@ export class FakeContacts extends FakeJmap { readonly refuse = new Map(); /** 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) => { jmapError: string } | { http: number } | null) | null = null; constructor(books: string[] = ['book1']) { super(); @@ -131,7 +150,9 @@ export class FakeContacts extends FakeJmap { 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++}`; - account.cards.set(id, { '@type': 'Card', version: '1.0', ...raw, id }); + 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 }; } @@ -142,7 +163,8 @@ export class FakeContacts extends FakeJmap { if (refused) notUpdated[id] = refused; else if (!card) notUpdated[id] = { type: 'notFound' }; else { - applyServerPatch(card, patch); + applyServerPatch(card, structuredClone(patch)); + if (this.mediaAsBlobs) storedMedia(card); account.coll.ContactCard.push('updated', id); updated[id] = null; } @@ -187,7 +209,12 @@ export class FakeContacts extends FakeJmap { 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)[filter.inAddressBook as string]); - const position = Number(args.position ?? 0); + 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, @@ -200,8 +227,8 @@ export class FakeContacts extends FakeJmap { } case 'ContactCard/get': { const ids = args.ids as string[] | null | undefined; - if (ids && ids.length > 500) throw Object.assign(new Error('requestTooLarge'), { jmapError: 'requestTooLarge' }); - const chosen = ids ?? [...account.cards.keys()].slice(0, 500); + 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(), @@ -209,8 +236,12 @@ export class FakeContacts extends FakeJmap { notFound: chosen.filter((id) => !account.cards.has(id)), }; } - case 'ContactCard/set': + 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); } @@ -219,7 +250,15 @@ export class FakeContacts extends FakeJmap { override async request(calls: MethodCall[], using?: string[]): Promise { // 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'); diff --git a/lib/store/contacts/methods.ts b/lib/store/contacts/methods.ts index f3c885909..9ef5a391f 100644 --- a/lib/store/contacts/methods.ts +++ b/lib/store/contacts/methods.ts @@ -15,10 +15,14 @@ function jmapOf(ctx: MethodContext, args: Record): string { return (args.accountId as string | undefined) ?? ctx.session.primaryAccounts[CONTACTS_CAPABILITY] ?? ctx.defaultJmap; } -/** The last state from the server. Throws when this type was never synced, so an empty answer always means empty. */ +/** + * 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)) throw new MethodError('serverUnavailable', 'Contacts not kept yet'); + if (!row || (!row.state && row.phase !== 'list')) throw new MethodError('serverUnavailable', 'Contacts not kept yet'); return row.state; } diff --git a/lib/store/contacts/patch.ts b/lib/store/contacts/patch.ts index 07247b3d9..61a5c1050 100644 --- a/lib/store/contacts/patch.ts +++ b/lib/store/contacts/patch.ts @@ -67,9 +67,13 @@ export function touchedProperties(patch: Patch): string[] { return [...new Set(Object.keys(patch).map((path) => steps(path)[0]))]; } -/** Deep equality that ignores key order. Absent and null count as the same. */ +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 || (a == null && b == null)) return true; + 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])); } @@ -79,10 +83,27 @@ export function sameJson(a: unknown, b: unknown): boolean { return false; } -/** Whether `actual` holds everything in `wanted`. A server may add properties of its own. */ -export function coversJson(wanted: unknown, actual: unknown): boolean { - if (isObject(wanted) && isObject(actual)) { - return Object.keys(wanted).every((key) => coversJson(wanted[key], actual[key])); +/** 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; } - return sameJson(wanted, actual); + for (const key of Object.keys(actual)) { + if (key in wanted || (inline && key === 'blobId')) continue; + if (before[key] != null) return false; + } + return true; } diff --git a/lib/store/contacts/pending.ts b/lib/store/contacts/pending.ts index 4cf2feba2..f6d2bdc78 100644 --- a/lib/store/contacts/pending.ts +++ b/lib/store/contacts/pending.ts @@ -12,11 +12,21 @@ * - 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, which ends up the same way. + * 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 { responseFor, type MethodCall } from '../engine/transport'; +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'; @@ -26,11 +36,12 @@ export const STAND_IN_PREFIX = 'bulwark-local-card-'; const LOCAL_BLOB_MARK = '"blobId":"bulwark-local-'; const SET_BATCH = 100; -const SET_BATCH_BYTES = 5 * 1024 * 1024; -/** The server takes 10 MB a request. A bigger card would hold up everything queued behind it, for good. */ -const MAX_RECORD_BYTES = 8 * 1024 * 1024; -const GET_BATCH = 500; -const CALLS_PER_REQUEST = 16; + +/** 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. */ @@ -45,6 +56,7 @@ export interface PendingRow { payload: string | null; base: string | null; attempted: number; + sent: string | null; queued_at: number; } @@ -105,7 +117,7 @@ export function queueCreate(ctx: StoreContext, jmap: string, card: JsonObject): 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 > MAX_RECORD_BYTES) return TOO_LARGE_ERROR; + 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 }; @@ -127,11 +139,11 @@ export function queueUpdate(ctx: StoreContext, jmap: string, rawId: string, patc } if (pending?.op === 'create') { const payload = JSON.stringify(applyPatch(JSON.parse(pending.payload as string) as JsonObject, patch)); - if (payload.length > MAX_RECORD_BYTES) return TOO_LARGE_ERROR; + 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 > MAX_RECORD_BYTES) return TOO_LARGE_ERROR; + 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); @@ -228,30 +240,44 @@ function withoutStamps(card: JsonObject): JsonObject { return copy; } -async function readServerCards(ctx: SyncContext, jmap: string, ids: string[]): Promise> { +async function readServerCards(ctx: SyncContext, jmap: string, ids: string[], limits: RequestLimits): Promise> { const cards = new Map(); - for (let i = 0; i < ids.length; i += GET_BATCH) { - const response = await ctx.transport.request([['ContactCard/get', { accountId: jmap, ids: ids.slice(i, i + GET_BATCH) }, 'g']], CONTACTS_USING); + 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 ids the server has for these uids. */ -async function lookUpUids(ctx: SyncContext, jmap: string, uids: string[]): Promise> { - const found = new Map(); - for (let i = 0; i < uids.length; i += CALLS_PER_REQUEST) { - const group = uids.slice(i, i + CALLS_PER_REQUEST); +/** The server's cards with these uids. */ +async function cardsByUid(ctx: SyncContext, jmap: string, uids: string[], limits: RequestLimits): Promise> { + const ids = new Map(); + 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 ids = responseFor(response, `q${n}`)[1].ids as string[]; - if (ids.length > 0) found.set(uid, ids[0]); + const found = responseFor(response, `q${n}`)[1].ids as string[]; + if (found.length > 0) ids.set(uid, found[0]); }); } - return found; + const cards = await readServerCards(ctx, jmap, [...ids.values()], limits); + const byUid = new Map(); + 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; @@ -282,29 +308,59 @@ function settleCreated(ctx: SyncContext, sent: PendingRow, server: Card): void { interface Decision { create: Map; update: Map; destroy: Map } +/** + * 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, 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) { - settleCreated(ctx, row, { ...(JSON.parse(row.payload as string) as JsonObject), ...uidHit }); - return; + 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); } - out.create.set(id, row); return; } if (row.op === 'destroy' && id.startsWith(STAND_IN_PREFIX)) { - // A create that may have gone out, deleted since. Without a card on the server, nothing to do. + // A create that may have gone out, deleted since. Only a card holding what was sent is ours to delete. dropPending(db, jmap, id); - if (uidHit) savePending(db, ctx, { jmap, id: uidHit.id, op: 'destroy', payload: null, base: JSON.stringify(uidHit) }); + 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); - } else if (row.base && !sameJson(withoutStamps(JSON.parse(row.base) as JsonObject), withoutStamps(server))) { + 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); @@ -320,7 +376,7 @@ function decide(ctx: SyncContext, row: PendingRow, server: Card | undefined, ser return; } const patch = JSON.parse(row.payload as string) as Patch; - const base = row.base ? JSON.parse(row.base) as JsonObject : null; + const base = row.base ? JSON.parse(row.base) as JsonObject : {}; let applied: Card | null = null; try { applied = applyPatch(server, patch); @@ -328,12 +384,12 @@ function decide(ctx: SyncContext, row: PendingRow, server: Card | undefined, ser if (!(error instanceof InvalidPatchError)) throw error; } const touched = touchedProperties(patch); - if (applied && touched.every((key) => coversJson((applied as Card)[key], server[key]))) { + 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 || (base && touched.some((key) => !sameJson(base[key], server[key])))) { + if (!applied || movedOnServer(row, server, touched)) { recordConflict(ctx, row, 'changed', server, null); dropPending(db, jmap, id); writeCard(db, jmap, server); @@ -342,125 +398,176 @@ function decide(ctx: SyncContext, row: PendingRow, server: Card | undefined, ser out.update.set(id, row); } -function settleUpdated(ctx: SyncContext, sent: PendingRow, server: Card): void { - const now = pendingFor(ctx.db, sent.jmap, sent.id); +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(ctx.db, sent.jmap, sent.id); + 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 was sent. - const base = applyPatch(server, JSON.parse(sent.payload as string) as Patch); - ctx.db.run('UPDATE contact_pending SET base = ? WHERE seq = ?', [JSON.stringify(base), now.seq]); + // 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, row: PendingRow, error: SetError, server: Card | undefined): void { +function settleRefused(ctx: SyncContext, sent: PendingRow, error: SetError, server: Card | undefined): void { const { db } = ctx; if (TRANSIENT_ERRORS.has(error.type)) return; - if (row.op === 'destroy' && error.type === 'notFound') { - dropPending(db, row.jmap, row.id); + 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, row, kind, server ?? null, error); - dropPending(db, row.jmap, row.id); - if (server && kind === 'refused') writeCard(db, row.jmap, server); - else removeCard(db, row.jmap, row.id); + 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): PendingRow[] { - const rows = db.all('SELECT * FROM contact_pending WHERE jmap = ? AND seq > ? ORDER BY seq LIMIT ?', [jmap, afterSeq, SET_BATCH]); +function nextBatch(db: StorageAdapter, jmap: string, afterSeq: number, limits: RequestLimits): PendingRow[] { + const rows = db.all('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 > SET_BATCH_BYTES) break; + 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 { + 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(); + 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 { + 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 { - const { db } = ctx; + const limits = requestLimits(ctx.session); let afterSeq = 0; let settled = false; - for (;;) { - if (ctx.stopped()) break; - const batch = nextBatch(db, jmap, afterSeq); - if (batch.length === 0) break; - afterSeq = batch[batch.length - 1].seq; - settled = true; - - const current = await readServerCards(ctx, jmap, batch.filter((row) => row.op !== 'create' && !row.id.startsWith(STAND_IN_PREFIX)).map((row) => row.id)); - const attempted = batch.filter((row) => row.uid && row.attempted).map((row) => row.uid as string); - const uidIds = await lookUpUids(ctx, jmap, attempted); - const uidCards = await readServerCards(ctx, jmap, [...uidIds.values()]); - const serverByUid = new Map(); - for (const [uid, id] of uidIds) { - const card = uidCards.get(id); - if (card) serverByUid.set(uid, card); - } - - const out: Decision = { create: new Map(), update: new Map(), destroy: new Map() }; - db.transaction(() => { - for (const { seq, id } of batch) { - // 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()) db.run('UPDATE contact_pending SET attempted = 1 WHERE seq = ?', [row.seq]); - }); - if (out.create.size + out.update.size + out.destroy.size === 0) continue; - - 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'); - - const refusedUid: Array<[PendingRow, SetError]> = []; - db.transaction(() => { - for (const [cid, row] of creationIds) { - const created = result.created?.[cid] as JsonObject | undefined; - const refused = result.notCreated?.[cid] as SetError | undefined; - if (created) { - settleCreated(ctx, row, { ...(JSON.parse(row.payload as string) as JsonObject), ...created, id: String(created.id) }); - } else if (refused?.properties?.includes('uid')) { - refusedUid.push([row, refused]); - } 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 (result.updated && id in result.updated) settleUpdated(ctx, row, current.get(id) as Card); - } - 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 has a card with this uid already: this one, sent before. - if (refusedUid.length > 0) { - const ids = await lookUpUids(ctx, jmap, refusedUid.map(([row]) => row.uid as string)); - const cards = await readServerCards(ctx, jmap, [...ids.values()]); - db.transaction(() => { - for (const [row, refused] of refusedUid) { - const id = ids.get(row.uid as string); - const card = id ? cards.get(id) : undefined; - if (card) settleCreated(ctx, row, { ...(JSON.parse(row.payload as string) as JsonObject), ...card }); - else settleRefused(ctx, row, refused, undefined); - } - }); + 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'] }); } - if (settled) ctx.emit({ type: 'changed', account: ctx.account, collections: ['ContactCard'] }); } diff --git a/lib/store/contacts/schema.ts b/lib/store/contacts/schema.ts index 66b30832c..9aceaf22b 100644 --- a/lib/store/contacts/schema.ts +++ b/lib/store/contacts/schema.ts @@ -48,6 +48,8 @@ export const CONTACT_MIGRATIONS: readonly Migration[] = [ 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) ); diff --git a/lib/store/contacts/sync.ts b/lib/store/contacts/sync.ts index f734263d6..b06ddc835 100644 --- a/lib/store/contacts/sync.ts +++ b/lib/store/contacts/sync.ts @@ -2,18 +2,24 @@ * 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. + * 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 { responseFor, type MethodCall } from '../engine/transport'; +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 PAGE = 500; -const PAIRS_PER_REQUEST = 4; +const MOST_PAIRS_PER_REQUEST = 4; + +function pageSize(ctx: SyncContext): number { + return requestLimits(ctx.session).maxObjectsInGet; +} // ---- Address books ---- @@ -42,8 +48,9 @@ async function syncBooks(ctx: SyncContext, jmap: string): Promise { apply: async (page) => { const ids = [...page.created, ...page.updated]; const list: Array<{ id: string }> = []; - for (let i = 0; i < ids.length; i += PAGE) { - const response = await ctx.transport.request([['AddressBook/get', { accountId: jmap, ids: ids.slice(i, i + PAGE), properties: ADDRESS_BOOK_PROPERTIES }, 'b']], CONTACTS_USING); + 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(() => { @@ -59,14 +66,16 @@ async function syncBooks(ctx: SyncContext, jmap: string): Promise { // ---- Cards ---- -interface ListCursor { position: number; gen: number; captured: string } +interface ListCursor { position: number; anchor: string | null; gen: number; captured: string } async function startFullList(ctx: SyncContext, jmap: string): Promise { 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, gen, captured }; - ctx.db.transaction(() => ctx.setSyncState(jmap, 'ContactCard', { state: null, phase: 'list', cursor: { ...cursor } })); + 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; } @@ -82,27 +91,47 @@ function sweep(ctx: SyncContext, jmap: string, gen: number): void { async function runFullList(ctx: SyncContext, jmap: string, start: ListCursor): Promise { 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_PER_REQUEST; i += 1) { - calls.push(['ContactCard/query', { accountId: jmap, position: cursor.position + i * PAGE, limit: PAGE, calculateTotal: i === 0 }, `q${i}`]); + 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[] = []; - for (let i = 0; i < PAIRS_PER_REQUEST && !short; i += 1) { - const [, query] = responseFor(response, `q${i}`); - if (i === 0 && typeof query.total === 'number') total = query.total; - cards.push(...responseFor(response, `g${i}`)[1].list as Card[]); - got += (query.ids as string[]).length; - short = (query.ids as string[]).length < PAGE; + 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; } - cursor.position += got; - const done = got === 0 || (total === null ? short : cursor.position >= total); + 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) { @@ -132,8 +161,9 @@ async function syncDelta(ctx: SyncContext, jmap: string, since: string, target: apply: async (page) => { const ids = [...page.created, ...page.updated]; const fetched: Card[] = []; - for (let i = 0; i < ids.length; i += PAGE) { - const response = await ctx.transport.request([['ContactCard/get', { accountId: jmap, ids: ids.slice(i, i + PAGE) }, 'g']], CONTACTS_USING); + 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(() => { @@ -151,7 +181,7 @@ async function syncDelta(ctx: SyncContext, jmap: string, since: string, target: async function syncCards(ctx: SyncContext, jmap: string): Promise { const row = ctx.syncState(jmap, 'ContactCard'); - let cursor = row.phase === 'list' ? row.cursor as unknown as ListCursor : null; + 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); @@ -170,7 +200,14 @@ export const contactsSyncer: Syncer = { name: 'contacts', capability: CONTACTS_CAPABILITY, async run(ctx, jmap) { - await flushPending(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; diff --git a/lib/store/engine/limits.ts b/lib/store/engine/limits.ts new file mode 100644 index 000000000..e79f8da8e --- /dev/null +++ b/lib/store/engine/limits.ts @@ -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; + 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'), + }; +} From 4c242e6a0cc530f40c1e9af4efc0045d514830e2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:20:40 +0100 Subject: [PATCH 07/35] fix: keep an account's contacts when only another account could answer --- lib/jmap/client.ts | 36 +++++++++++------- lib/jmap/partial-accounts.ts | 27 ++++++++++++++ lib/store/__tests__/client-contacts.test.ts | 23 ++++++++++++ stores/__tests__/contact-store.test.ts | 41 +++++++++++++++++++++ stores/contact-store.ts | 30 ++++++++++++--- 5 files changed, 138 insertions(+), 19 deletions(-) create mode 100644 lib/jmap/partial-accounts.ts diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 56258dd86..af3a7e095 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -19,6 +19,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"; @@ -7327,13 +7328,16 @@ export class JMAPClient implements IJMAPClient { } } - /** Throws when no account could answer, so a failure doesn't read as having no address books. */ + /** + * 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 { - let answered = 0; + 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) { @@ -7356,23 +7360,23 @@ export class JMAPClient implements IJMAPClient { isShared: !isPrimary, })); allBooks.push(...books); - answered += 1; } 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; } } - if (answered > 0 || !failure) return allBooks; + if (failed.length === 0) return allBooks; } catch (error) { console.error('Failed to fetch all address books:', error); return this.getAddressBooks(); } - throw failure; + throw new PartialAccountsError(allBooks, failed, primaryId, failure); } async createAddressBook(name: string): Promise { @@ -8286,13 +8290,17 @@ export class JMAPClient implements IJMAPClient { } } - /** Throws when no account could answer, so a failure doesn't read as an empty address book. */ + /** + * 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 { - let answered = 0; + 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) { @@ -8313,19 +8321,19 @@ export class JMAPClient implements IJMAPClient { isShared: !isPrimary, })); allContacts.push(...contacts); - answered += 1; } catch (error) { console.error(`Failed to fetch contacts for account ${accountId}:`, error); + failed.push(accountId); failure ??= error; } } - if (answered > 0 || !failure) return allContacts; + if (failed.length === 0) return allContacts; } catch (error) { console.error('Failed to fetch all contacts:', error); return this.getContacts(); } - throw failure; + throw new PartialAccountsError(allContacts, failed, primaryId, failure); } async getContact(contactId: string, accountId?: string): Promise { diff --git a/lib/jmap/partial-accounts.ts b/lib/jmap/partial-accounts.ts new file mode 100644 index 000000000..66d3798a8 --- /dev/null +++ b/lib/jmap/partial-accounts.ts @@ -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 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(error: unknown): PartialAccountsError | null { + return error instanceof Error && error.name === 'PartialAccountsError' ? error as PartialAccountsError : null; +} + +/** What to show after a partial listing: the answers, and what's held for the accounts that failed. */ +export function withFailedAccountsKept(partial: PartialAccountsError, held: readonly T[]): T[] { + const failed = new Set(partial.failedAccountIds); + return [...partial.items, ...held.filter((item) => failed.has(item.accountId ?? partial.primaryAccountId))]; +} diff --git a/lib/store/__tests__/client-contacts.test.ts b/lib/store/__tests__/client-contacts.test.ts index 87d516a34..7e97c18f3 100644 --- a/lib/store/__tests__/client-contacts.test.ts +++ b/lib/store/__tests__/client-contacts.test.ts @@ -6,6 +6,8 @@ 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'; @@ -80,6 +82,27 @@ describe('the client over the store', () => { 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 = { + 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(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(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']); diff --git a/stores/__tests__/contact-store.test.ts b/stores/__tests__/contact-store.test.ts index f9dfabcca..f52aa4e11 100644 --- a/stores/__tests__/contact-store.test.ts +++ b/stores/__tests__/contact-store.test.ts @@ -13,6 +13,7 @@ import type { AddressBook, ContactCard, ContactMedia } from '@/lib/jmap/types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { clearContactPhotoCache } from '@/lib/contact-photo-cache'; import { PrincipalsRequestError } from '@/lib/jmap/principals-error'; +import { PartialAccountsError } from '@/lib/jmap/partial-accounts'; import { setClientLookup } from '../client-registry'; vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-0000-0000-000000000000' }); @@ -1387,6 +1388,46 @@ describe('contact-store', () => { expect(state.addressBooks.map((b) => b.id)).toEqual(['acc-a::booka', 'acc-b::bookb']); }); + it('keeps the primary account\'s cards when only a shared account answered', async () => { + const refusal = new PartialAccountsError([makeContact({ id: 'shared:s2', accountId: 'shared', isShared: true })], ['me'], 'me', new Error('offline')); + const failing = { getAllContacts: vi.fn().mockRejectedValue(refusal) } as unknown as IJMAPClient; + useContactStore.setState({ + contacts: [ + makeContact({ id: 'p1', accountId: 'me' }), + // A card just created has no accountId yet: it's the primary account's. + makeContact({ id: 'p2' }), + makeContact({ id: 'shared:s1', accountId: 'shared', isShared: true }), + ], + }); + await useContactStore.getState().fetchContacts(failing); + const state = useContactStore.getState(); + expect(state.contacts.map((c) => c.id).sort()).toEqual(['p1', 'p2', 'shared:s2']); + expect(state.error).toBe('Failed to fetch contacts'); + expect(state.isLoading).toBe(false); + }); + + it('keeps the primary account\'s books when only a shared account answered', async () => { + const refusal = new PartialAccountsError([{ id: 'shared:b', name: 'B', accountId: 'shared' } as AddressBook], ['me'], 'me', new Error('offline')); + const failing = { getAllAddressBooks: vi.fn().mockRejectedValue(refusal) } as unknown as IJMAPClient; + useContactStore.setState({ addressBooks: [{ id: 'mine', name: 'Mine', accountId: 'me' } as AddressBook] }); + await useContactStore.getState().fetchAddressBooks(failing); + expect(useContactStore.getState().addressBooks.map((b) => b.id).sort()).toEqual(['mine', 'shared:b']); + }); + + it('keeps a failed JMAP account of one login while its others refresh', async () => { + const refusal = new PartialAccountsError([makeContact({ id: 's2', accountId: 'shared' })], ['me'], 'me', new Error('offline')); + const failing = { getAllContacts: vi.fn().mockRejectedValue(refusal) } as unknown as IJMAPClient; + useContactStore.setState({ + contacts: [ + makeContact({ id: 'acc-a::p1', accountId: 'me', localAccountId: 'acc-a' }), + makeContact({ id: 'acc-a::s1', accountId: 'shared', localAccountId: 'acc-a' }), + makeContact({ id: 'acc-b::x', accountId: 'me', localAccountId: 'acc-b' }), + ], + }); + await useContactStore.getState().fetchAllAccountsContacts([{ client: failing, localAccountId: 'acc-a' }]); + expect(useContactStore.getState().contacts.map((c) => c.id).sort()).toEqual(['acc-a::p1', 'acc-a::s2']); + }); + it('keeps the list when the only account fails', async () => { const failing = { getAllContacts: vi.fn().mockRejectedValue(new Error('offline')) } as unknown as IJMAPClient; useContactStore.setState({ contacts: [makeContact()] }); diff --git a/stores/contact-store.ts b/stores/contact-store.ts index 3403088b2..924dfc99a 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -13,6 +13,7 @@ import { sanitizeDisplayName, splitMailbox } from '@/lib/rfc5322-mailbox'; import { inlineContactMedia, writeContactWithNewPhoto, type ContactSaveOptions } from '@/lib/contact-media'; import { contactToView } from '@/lib/plugin-projection'; import { PrincipalsRequestError } from '@/lib/jmap/principals-error'; +import { PartialAccountsError, partialAccounts, withFailedAccountsKept } from '@/lib/jmap/partial-accounts'; import type { Principal } from '@/lib/jmap/types'; import { currentStoreEpoch } from '@/lib/store-epoch'; @@ -614,7 +615,14 @@ export const useContactStore = create()( } catch (error) { if (currentStoreEpoch() !== epoch) return; console.error('Failed to fetch contacts:', error); - set({ error: 'Failed to fetch contacts', isLoading: false, contactsLoaded: true }); + // An account that couldn't answer keeps its cards; the others are fresh. + const partial = partialAccounts(error); + set((state) => ({ + error: 'Failed to fetch contacts', + isLoading: false, + contactsLoaded: true, + contacts: partial ? withFailedAccountsKept(partial, state.contacts) : state.contacts, + })); } }, @@ -656,7 +664,11 @@ export const useContactStore = create()( } catch (error) { if (currentStoreEpoch() !== epoch) return; console.error('Failed to fetch address books:', error); - set({ error: 'Failed to fetch address books' }); + const partial = partialAccounts(error); + set((state) => ({ + error: 'Failed to fetch address books', + addressBooks: partial ? withFailedAccountsKept(partial, state.addressBooks) : state.addressBooks, + })); } }, @@ -671,8 +683,12 @@ export const useContactStore = create()( return prefixContactsWithLocalAccount(list, localAccountId); } catch (error) { debug.error(`Failed to fetch contacts for account ${localAccountId}:`, error); - // Keep what this account's cards were: it just couldn't answer. - return get().contacts.filter((contact) => contact.localAccountId === localAccountId); + // Keep what this login's cards were: it just couldn't answer. + const held = get().contacts.filter((contact) => contact.localAccountId === localAccountId); + const partial = partialAccounts(error); + if (!partial) return held; + const fresh = prefixContactsWithLocalAccount(partial.items, localAccountId); + return withFailedAccountsKept(new PartialAccountsError(fresh, partial.failedAccountIds, partial.primaryAccountId, error), held); } }), ); @@ -695,7 +711,11 @@ export const useContactStore = create()( return prefixAddressBooksWithLocalAccount(list, localAccountId); } catch (error) { debug.error(`Failed to fetch address books for account ${localAccountId}:`, error); - return get().addressBooks.filter((book) => book.localAccountId === localAccountId); + const held = get().addressBooks.filter((book) => book.localAccountId === localAccountId); + const partial = partialAccounts(error); + if (!partial) return held; + const fresh = prefixAddressBooksWithLocalAccount(partial.items, localAccountId); + return withFailedAccountsKept(new PartialAccountsError(fresh, partial.failedAccountIds, partial.primaryAccountId, error), held); } }), ); From c76ba0fee22c4b4383d7f6785126f6f4ab2b3b10 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:11:43 +0100 Subject: [PATCH 08/35] feat: let organisations cap what devices keep offline --- .../org/__tests__/offline-section.test.tsx | 27 ++++- .../admin/org/sections/offline-section.tsx | 103 ++++++++++++++++-- lib/__tests__/device-policy.test.ts | 51 +++++++++ .../server-device-offline-caps.test.ts | 29 +++++ lib/admin/organisation-policy.ts | 61 ++++++++++- lib/admin/server-device-policy.ts | 13 +-- lib/device-policy.ts | 13 +-- 7 files changed, 263 insertions(+), 34 deletions(-) create mode 100644 lib/admin/__tests__/server-device-offline-caps.test.ts diff --git a/components/admin/org/__tests__/offline-section.test.tsx b/components/admin/org/__tests__/offline-section.test.tsx index c678f11e6..a5051d4fb 100644 --- a/components/admin/org/__tests__/offline-section.test.tsx +++ b/components/admin/org/__tests__/offline-section.test.tsx @@ -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); }); }); diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx index 17f07df1d..9494654ca 100644 --- a/components/admin/org/sections/offline-section.tsx +++ b/components/admin/org/sections/offline-section.tsx @@ -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 is removed from the device and opened from the server when there is a connection.', + 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 (
- draft.setOrgValue('devices', 'noOffline', v)} testId="org-devices-noOffline" /> - draft.clearOrgValue('devices', 'noOffline')} /> + draft.setOrgValue('devices', 'noOffline', v)} testId="org-devices-noOffline" /> + draft.clearOrgValue('devices', 'noOffline')} /> +
+
+ {CAP_FIELDS.map((field) => { + const { value, own, everyone } = draft.orgValue('devices', field.key); + return ( + +
+
+ 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" + /> + {field.unit} +
+ draft.clearOrgValue('devices', field.key)} /> +
+
+ ); + })} + +
+ draft.setOrgValue('devices', 'noOfflineAttachments', v)} testId="org-devices-noOfflineAttachments" /> + draft.clearOrgValue('devices', 'noOfflineAttachments')} />

- 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 in the browser. That protects against someone copying the browser's + files, not against someone using the unlocked browser. 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.{' '} How offline mode works diff --git a/lib/__tests__/device-policy.test.ts b/lib/__tests__/device-policy.test.ts index fa850e29d..5b1cc5197 100644 --- a/lib/__tests__/device-policy.test.ts +++ b/lib/__tests__/device-policy.test.ts @@ -89,6 +89,57 @@ describe('which account decides', () => { }); }); +describe('offline caps', () => { + const scoped = (id: string, match: object, devices: Record) => ({ id, label: id, match, policy: { organisation: { devices } } }); + + function withPolicy(organisationDevices: Record, 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(() => {}); diff --git a/lib/admin/__tests__/server-device-offline-caps.test.ts b/lib/admin/__tests__/server-device-offline-caps.test.ts new file mode 100644 index 000000000..a6661ea25 --- /dev/null +++ b/lib/admin/__tests__/server-device-offline-caps.test.ts @@ -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) => ({ 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 }); + }); +}); diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts index 8467c42c8..b49c3f812 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -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 = { 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 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 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'] { + const out = { ...base } as Record; + 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 = { 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; } diff --git a/lib/admin/server-device-policy.ts b/lib/admin/server-device-policy.ts index 89cd77084..f72376d44 100644 --- a/lib/admin/server-device-policy.ts +++ b/lib/admin/server-device-policy.ts @@ -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 | 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; /** diff --git a/lib/device-policy.ts b/lib/device-policy.ts index 45bce6358..3489e28df 100644 --- a/lib/device-policy.ts +++ b/lib/device-policy.ts @@ -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 ?? {}) }; } @@ -99,14 +97,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. */ From a7c3c5b9a68192679be1f253259d1b94fd22c8bd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:56:24 +0100 Subject: [PATCH 09/35] feat: prune kept mail at once when the scope tightens and fetch it back when it widens The store took a scope only when it opened and acted on it only at a full sync, so a lower cap left old mail on the device until something else forced a resync. Now the engine applies a changed scope first, without the network, and once a day as the window moves on. Also tell the page whether an account's file was there when it opened, so it can notice a store the browser deleted. - `calendar-sync.test.ts` now syncs with `days: null`: its old events test checks the calendar's own two years, and the calendar follows the scope now - `page-side.test.ts`: the fake worker answers an open with what it found, as the real one does --- lib/store/__tests__/calendar-sync.test.ts | 2 +- .../__tests__/helpers/in-process-store.ts | 7 +- lib/store/__tests__/host.test.ts | 111 ++++++ lib/store/__tests__/mail-sync.test.ts | 2 +- lib/store/__tests__/page-side.test.ts | 3 +- lib/store/__tests__/scope.test.ts | 315 ++++++++++++++++++ lib/store/adapters/wasm.ts | 6 +- lib/store/area.ts | 33 ++ lib/store/calendar/index.ts | 9 +- lib/store/calendar/sync.ts | 33 +- lib/store/client/api.ts | 31 +- lib/store/client/store-client.ts | 14 +- lib/store/engine/account-engine.ts | 90 ++++- lib/store/mail/calls.ts | 3 + lib/store/mail/index.ts | 4 + lib/store/mail/scope.ts | 102 ++++++ lib/store/mail/sync.ts | 6 +- lib/store/worker/host.ts | 49 ++- lib/store/worker/protocol.ts | 7 +- lib/store/worker/worker.ts | 13 +- 20 files changed, 795 insertions(+), 45 deletions(-) create mode 100644 lib/store/__tests__/host.test.ts create mode 100644 lib/store/__tests__/scope.test.ts create mode 100644 lib/store/mail/scope.ts diff --git a/lib/store/__tests__/calendar-sync.test.ts b/lib/store/__tests__/calendar-sync.test.ts index 2b3e90c65..57df95cb6 100644 --- a/lib/store/__tests__/calendar-sync.test.ts +++ b/lib/store/__tests__/calendar-sync.test.ts @@ -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 () => {}, diff --git a/lib/store/__tests__/helpers/in-process-store.ts b/lib/store/__tests__/helpers/in-process-store.ts index 81699f3a3..fb7119c2f 100644 --- a/lib/store/__tests__/helpers/in-process-store.ts +++ b/lib/store/__tests__/helpers/in-process-store.ts @@ -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'; @@ -75,6 +75,11 @@ export class InProcessStore implements StoreBackend { return (result === undefined ? undefined : structuredClone(result)) as T; } + async setScope(account: string, scope: StoreScope): Promise { + const engine = this.engine(account); + if (engine.setScope(scope)) await engine.requestSync(); + } + async sync(account: string): Promise { await this.engine(account).requestSync(); } diff --git a/lib/store/__tests__/host.test.ts b/lib/store/__tests__/host.test.ts new file mode 100644 index 000000000..cff241a70 --- /dev/null +++ b/lib/store/__tests__/host.test.ts @@ -0,0 +1,111 @@ +// @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); + +function keyError(): Error { + const error = new Error('The local store could not be opened with this key'); + error.name = 'StoreKeyError'; + return error; +} + +class FakeStorage implements HostStorage { + readonly dbs = new Map(); + failNext: Error | null = null; + removed: string[] = []; + files(): string[] { + return [...this.dbs.keys()]; + } + async open(file: string): Promise { + if (this.failNext) { + const error = this.failNext; + this.failNext = null; + throw error; + } + 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[0]; + +describe('opening an account', () => { + it('says whether its file was there', async () => { + const storage = new FakeStorage(); + const first = host(storage); + expect(await first.openAccount(params('/a1.db'))).toEqual({ fileExisted: false }); + expect(await first.openAccount(params('/a1.db'))).toEqual({ fileExisted: true }); + await first.closeAll(); + expect(await host(storage).openAccount(params('/a1.db'))).toEqual({ fileExisted: true }); + }); + + it('makes no empty file while it has no session, so the next open can still tell', async () => { + const storage = new FakeStorage(); + await expect(host(storage).openAccount(params('/a1.db', null))).rejects.toThrow('No session'); + expect(storage.files()).toEqual([]); + expect(await host(storage).openAccount(params('/a1.db'))).toEqual({ fileExisted: false }); + }); + + it('starts over from a file its key can\'t read, and says the file is gone', async () => { + const storage = new FakeStorage(); + storage.dbs.set('/a1.db', openMemoryStore()); + storage.failNext = keyError(); + expect(await host(storage).openAccount(params('/a1.db'))).toEqual({ fileExisted: false }); + expect(storage.removed).toEqual(['/a1.db']); + }); + + it('never deletes a file over any other error', async () => { + const storage = new FakeStorage(); + storage.dbs.set('/a1.db', openMemoryStore()); + storage.failNext = new Error('disk I/O error'); + await expect(host(storage).openAccount(params('/a1.db'))).rejects.toThrow('disk I/O'); + expect(storage.removed).toEqual([]); + }); +}); + +describe('files with no key', () => { + it('are deleted, journals included; known ones and those in use stay', async () => { + const storage = new FakeStorage(); + const running = host(storage); + await running.openAccount(params('/ainuse.db')); + storage.dbs.set('/aknown.db', openMemoryStore()); + storage.dbs.set('/aorphan.db', openMemoryStore()); + storage.dbs.set('/aorphan.db-journal', openMemoryStore()); + expect(running.sweepFiles(['/aknown.db'])).toBeGreaterThan(0); + expect(storage.files().sort()).toEqual(['/ainuse.db', '/aknown.db']); + }); +}); + +describe('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' }); + }); +}); diff --git a/lib/store/__tests__/mail-sync.test.ts b/lib/store/__tests__/mail-sync.test.ts index 13d438c6c..e4424a4a3 100644 --- a/lib/store/__tests__/mail-sync.test.ts +++ b/lib/store/__tests__/mail-sync.test.ts @@ -443,7 +443,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, defaultJmap: JMAP_ACCOUNT, invertedKeywordSort: false }, buildMethodTable([mailArea]), [['Email/query', { filter: INBOX, sort: [{ property: 'receivedAt', isAscending: false }], limit: 5 }, 'q']], ); diff --git a/lib/store/__tests__/page-side.test.ts b/lib/store/__tests__/page-side.test.ts index ede57905f..045919141 100644 --- a/lib/store/__tests__/page-side.test.ts +++ b/lib/store/__tests__/page-side.test.ts @@ -25,6 +25,7 @@ class FakeChannel { class FakeWorker { static all: FakeWorker[] = []; + static opened = { fileExisted: true }; onmessage: ((event: MessageEvent) => void) | null = null; posted: Array> = []; terminated = false; @@ -33,7 +34,7 @@ class FakeWorker { } postMessage(message: Record): void { this.posted.push(message); - if (message.kind === 'open') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id } } as MessageEvent)); + if (message.kind === 'open') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: FakeWorker.opened } } as MessageEvent)); } terminate(): void { this.terminated = true; diff --git a/lib/store/__tests__/scope.test.ts b/lib/store/__tests__/scope.test.ts new file mode 100644 index 000000000..eea9d21be --- /dev/null +++ b/lib/store/__tests__/scope.test.ts @@ -0,0 +1,315 @@ +// @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 { 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(); + override async download(_jmap?: string, blobId?: string): Promise { + 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, table = 'email') => db.all<{ id: string }>(`SELECT id FROM ${table} ORDER BY id`).map((row) => row.id); +const blobs = (db: ReturnType) => db.all<{ blob_id: string }>('SELECT blob_id FROM blob ORDER BY blob_id').map((row) => row.blob_id); +const count = (db: ReturnType, 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('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])); + expect(parts.attachments).toEqual({ part: 'attachments', bytes: 50_000, items: 1 }); + expect(parts.mail.items).toBe(2); + expect(parts.mail.bytes).toBe(before.bytes - 50_000); + + 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); + }); +}); diff --git a/lib/store/adapters/wasm.ts b/lib/store/adapters/wasm.ts index 2870e7eae..3a191cb50 100644 --- a/lib/store/adapters/wasm.ts +++ b/lib/store/adapters/wasm.ts @@ -159,8 +159,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}`); diff --git a/lib/store/area.ts b/lib/store/area.ts index c435a4bce..a7491229e 100644 --- a/lib/store/area.ts +++ b/lib/store/area.ts @@ -31,6 +31,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. */ @@ -57,6 +78,8 @@ export interface StoreContext { emit(event: StoreEvent): void; /** Records of one type as this device shows them: see RecordOverlay. */ overlay(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; } /** @@ -121,4 +144,14 @@ export interface StoreArea { syncers: readonly Syncer[]; calls?: Readonly>; 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; + /** For the storage page. */ + usage?: (ctx: Pick) => StoreUsagePart[]; + /** Attachments and file contents it holds: what counts towards `scope.maxTotalBytes`. */ + keptBytes?: (ctx: Pick) => number; } diff --git a/lib/store/calendar/index.ts b/lib/store/calendar/index.ts index 12d908660..c5e8de162 100644 --- a/lib/store/calendar/index.ts +++ b/lib/store/calendar/index.ts @@ -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,9 +9,16 @@ 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), 'calendar.conflicts': (ctx) => listConflicts(ctx.db), + /** How many writes wait to go out. */ + 'calendar.pending': (ctx) => Number(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_pending')?.n ?? 0), 'calendar.resolveStandIns': (ctx, params: { ids: string[] }) => resolveStandIns(ctx.db, params.ids), 'calendar.dismissConflict': (ctx, params: { id: number }) => { ctx.db.run('DELETE FROM calendar_conflict WHERE id = ?', [params.id]); diff --git a/lib/store/calendar/sync.ts b/lib/store/calendar/sync.ts index 71ad94a0c..73088d3fa 100644 --- a/lib/store/calendar/sync.ts +++ b/lib/store/calendar/sync.ts @@ -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 { 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 { } /** 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 }); + } + }); } /** diff --git a/lib/store/client/api.ts b/lib/store/client/api.ts index f74b0f52e..d2b1f5532 100644 --- a/lib/store/client/api.ts +++ b/lib/store/client/api.ts @@ -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, openAccountKey, type WrapLayer } from './keys'; +import { forgetAccountKey, getAccountRecord, listAccountRecords, openAccountKey, 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; } @@ -23,6 +24,8 @@ export interface StorePageHooks { /** Runs the client's refresh path; the fresh Authorization header, or null. */ refreshAuth(account: string): Promise; onEvent(event: StoreEvent): void; + /** What opening an account found: its key from before, and its file. Owner tab only. */ + onOpened?(account: string, found: { hadKey: boolean; fileExisted: boolean }): void; /** Wrap layers on top of the WebCrypto one (desktop keychain, app lock). */ keyLayers?(): readonly WrapLayer[]; } @@ -36,6 +39,8 @@ export interface StoreBackend { refresh(account: string): void; answer(account: string, calls: MethodCall[]): Promise; area(account: string, name: string, params?: unknown): Promise; + /** What the store may keep. A tightened scope prunes at once, also without a network. */ + setScope(account: string, scope: StoreScope): Promise; sync(account: string): Promise; noteStateChange(account: string, changed: Record>): Promise; setOnline(online: boolean): Promise; @@ -75,6 +80,7 @@ class PageStore implements StoreBackend { onOwner: () => { this.opening.clear(); for (const account of this.wanted.keys()) void this.openHere(account).catch(() => {}); + void this.sweepOrphans(); }, onNeedAuth: (account) => { void (this.hooks()?.refreshAuth(account) ?? Promise.resolve(null)) @@ -95,23 +101,35 @@ class PageStore implements StoreBackend { this.broken = false; } + /** Files whose key is gone can't be read by anyone: they only take room. */ + private async sweepOrphans(): Promise { + try { + const records = await listAccountRecords(); + await this.client?.sweepFiles(records.map((record) => record.file)); + } catch { + // Tried again the next time this tab or another becomes the owner. + } + } + /** Only the owner may open: the key goes to this tab's own worker, never over the channel. */ private openHere(account: string): Promise { const running = this.opening.get(account); if (running) return running; const run = (async () => { const client = this.started(); + const hadKey = Boolean(await getAccountRecord(account)); const { keyHex, record } = await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []); const inputs = this.hooks()?.inputs(account) ?? { session: null, auth: null, invertedKeywordSort: null }; - await client.openAccount({ + const { fileExisted } = await client.openAccount({ account, file: record.file, keyHex, session: inputs.session ?? this.wanted.get(account) ?? null, - scope: inputs.scope ?? DEFAULT_SCOPE, + scope: inputs.scope, invertedKeywordSort: inputs.invertedKeywordSort, auth: inputs.auth, }); + this.hooks()?.onOpened?.(account, { hadKey, fileExisted }); })(); this.opening.set(account, run); run.catch(() => this.opening.delete(account)); @@ -132,6 +150,7 @@ class PageStore implements StoreBackend { if (!inputs) return; if (inputs.session) client.setSession(account, inputs.session); client.setAuth(account, inputs.auth); + if (inputs.scope) void client.setScope(account, inputs.scope).catch(() => {}); if (inputs.invertedKeywordSort !== null) client.setInvertedKeywordSort(account, inputs.invertedKeywordSort); } @@ -157,6 +176,10 @@ class PageStore implements StoreBackend { return this.whenOpen(account, (client) => client.area(account, name, params)); } + setScope(account: string, scope: StoreScope): Promise { + return this.whenOpen(account, (client) => client.setScope(account, scope)); + } + sync(account: string): Promise { return this.whenOpen(account, (client) => client.sync(account)); } diff --git a/lib/store/client/store-client.ts b/lib/store/client/store-client.ts index 13b4af661..9935d6471 100644 --- a/lib/store/client/store-client.ts +++ b/lib/store/client/store-client.ts @@ -220,10 +220,14 @@ export class StoreClient { return this.isOwner; } - openAccount(params: OpenAccountParams): Promise { + openAccount(params: OpenAccountParams): Promise<{ fileExisted: boolean }> { return this.direct({ kind: 'open', id: crypto.randomUUID(), params }); } + sweepFiles(known: string[]): Promise { + return this.direct({ kind: 'sweep-files', id: crypto.randomUUID(), known }); + } + closeAccount(account: string): Promise { return this.direct({ kind: 'close-account', id: crypto.randomUUID(), account }); } @@ -236,10 +240,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 }); } @@ -283,6 +283,10 @@ export class StoreClient { return this.rpc('sync', { account }, SYNC_TIMEOUT_MS); } + setScope(account: string, scope: StoreScope): Promise { + return this.rpc('setScope', { account, scope }); + } + removeAccount(account: string, file: string): Promise { return this.rpc('removeAccount', { account, file }); } diff --git a/lib/store/engine/account-engine.ts b/lib/store/engine/account-engine.ts index df9452248..252ec0d84 100644 --- a/lib/store/engine/account-engine.ts +++ b/lib/store/engine/account-engine.ts @@ -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; @@ -36,6 +44,7 @@ export class AccountEngine { private session: StoreSession; private scope: StoreScope; private readonly transport: JmapTransport; + private readonly areas: readonly StoreArea[]; private readonly syncers: readonly Syncer[]; private readonly methods: ReadonlyMap; private readonly calls = new Map(); @@ -51,12 +60,14 @@ 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.areas = options.areas; this.transport = options.transport; this.syncers = options.areas.flatMap((area) => area.syncers); this.methods = buildMethodTable(options.areas); @@ -70,8 +81,12 @@ 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'); @@ -89,11 +104,64 @@ 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)); + metaSet(this.db, SCOPE_KEY, json); + return true; + } + + /** False when something more urgent than a sync came up: the disk is full. */ + private async applyScope(): Promise { + 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 { @@ -161,8 +229,9 @@ export class AccountEngine { private async runLoop(): Promise { 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 { @@ -173,10 +242,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() }); } @@ -233,10 +303,11 @@ export class AccountEngine { transport: this.transport, now: this.now, emit: this.emitter, - stopped: () => this.closed || !this.online, + stopped: () => this.closed || !this.online || this.scopeChanged, 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), }; } @@ -249,6 +320,7 @@ export class AccountEngine { now: this.now, emit: this.emitter, overlay: (jmap, type, records) => this.overlay(jmap, type, records), + keptByOthers: (area) => this.keptByOthers(area), }; } diff --git a/lib/store/mail/calls.ts b/lib/store/mail/calls.ts index 429b55dd5..7a3dbe441 100644 --- a/lib/store/mail/calls.ts +++ b/lib/store/mail/calls.ts @@ -34,6 +34,9 @@ function recordOpened(ctx: StoreContext, params: { jmap: string; emails: EmailOb /** A file of a kept message the user opened. Nothing else is kept: not an export, not a stray file. */ function recordBlob(ctx: StoreContext, params: { jmap: string; blobId: string; type: string | null; data: Uint8Array }): boolean { + if (!ctx.scope.attachments || params.data.byteLength > ctx.scope.maxAttachmentBytes) return false; + const used = Number(ctx.db.get<{ n: number | null }>('SELECT SUM(size) AS n FROM blob')?.n ?? 0); + if (used + ctx.keptByOthers('mail') + params.data.byteLength > ctx.scope.maxTotalBytes) return false; const owner = ctx.db.get<{ received_at: number }>('SELECT received_at FROM email_part WHERE jmap = ? AND blob_id = ?', [params.jmap, params.blobId]); if (!owner) return false; putBlob(ctx.db, params.jmap, params.blobId, params.type, params.data, Number(owner.received_at)); diff --git a/lib/store/mail/index.ts b/lib/store/mail/index.ts index aa0ba6e6b..452a199fc 100644 --- a/lib/store/mail/index.ts +++ b/lib/store/mail/index.ts @@ -2,6 +2,7 @@ import type { StoreArea } from '../area'; 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'; @@ -11,4 +12,7 @@ export const mailArea: StoreArea = { methods: MAIL_METHODS, syncers: [mailSyncer, identitySyncer, quotaSyncer], 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), }; diff --git a/lib/store/mail/scope.ts b/lib/store/mail/scope.ts new file mode 100644 index 000000000..d2cc201e5 --- /dev/null +++ b/lib/store/mail/scope.ts @@ -0,0 +1,102 @@ +/** + * 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; +const BLOB_BATCH = 20; + +/** 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 { + const before = cutoff(ctx, days); + let removed = false; + for (const { jmap } of ctx.db.all<{ jmap: string }>('SELECT DISTINCT jmap FROM email')) { + for (;;) { + if (ctx.stopped()) return; + // A change waiting for one of these goes out by its id, whether it's kept or not. + const rows = ctx.db.all<{ rowid: number }>('SELECT rowid FROM email WHERE jmap = ? AND received_at < ? LIMIT ?', [jmap, before, 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'] }); +} + +/** The rows stay in email_part, so a loosened scope can fetch them again. */ +async function dropBlobs(ctx: SyncContext, select: () => Array<{ jmap: string; blob_id: string }>): Promise { + for (;;) { + if (ctx.stopped()) return; + const rows = select(); + if (rows.length === 0) return; + ctx.db.transaction(() => { + for (const row of rows) ctx.db.run('DELETE FROM blob WHERE jmap = ? AND blob_id = ?', [row.jmap, row.blob_id]); + }); + await breathe(); + } +} + +export async function pruneAttachments(ctx: SyncContext): Promise { + const { scope } = ctx; + if (!scope.attachments) { + await dropBlobs(ctx, () => ctx.db.all('SELECT jmap, blob_id FROM blob LIMIT ?', [BLOB_BATCH])); + return; + } + await dropBlobs(ctx, () => ctx.db.all('SELECT jmap, blob_id FROM blob WHERE size > ? LIMIT ?', [scope.maxAttachmentBytes, BLOB_BATCH])); + // The total is for all kept files of the account, so what the Files area holds takes its part. + const allowed = scope.maxTotalBytes - ctx.keptByOthers('mail'); + await dropBlobs(ctx, () => { + const used = Number(ctx.db.get<{ n: number | null }>('SELECT SUM(size) AS n FROM blob')?.n ?? 0); + if (used <= allowed) return []; + const out: Array<{ jmap: string; blob_id: string }> = []; + let over = used - allowed; + for (const row of ctx.db.all<{ jmap: string; blob_id: string; size: number }>('SELECT jmap, blob_id, size FROM blob ORDER BY at ASC LIMIT ?', [BLOB_BATCH])) { + if (over <= 0) break; + out.push({ jmap: row.jmap, blob_id: row.blob_id }); + over -= Number(row.size); + } + return out; + }); +} + +export async function applyMailScope(ctx: SyncContext, { previous, next }: ScopeChange): Promise { + // 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); + await pruneAttachments(ctx); +} + +/** Message text and the search index aren't counted here: they are what's left of the file. */ +export function mailUsage(ctx: Pick): 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) }, + ]; +} diff --git a/lib/store/mail/sync.ts b/lib/store/mail/sync.ts index ebe43fecc..d1cff16d3 100644 --- a/lib/store/mail/sync.ts +++ b/lib/store/mail/sync.ts @@ -11,6 +11,7 @@ import type { SyncContext, Syncer } from '../area'; import { deleteObject, putObject } from '../engine/core-schema'; import { runChangesLoop } from '../engine/changes'; import { JmapMethodError, responseFor, TransportError, type MethodCall } from '../engine/transport'; +import { pruneAttachments } from './scope'; import { deleteEmail, deleteEmailRow, @@ -357,7 +358,10 @@ async function backfillBodies(ctx: SyncContext, jmap: string): Promise { */ async function fetchAttachments(ctx: SyncContext, jmap: string): Promise { if (!ctx.scope.attachments) return; - let used = Number(ctx.db.get<{ n: number | null }>('SELECT SUM(size) AS n FROM blob')?.n ?? 0); + // The Files area may have taken its part of the total since the last pass. + const others = ctx.keptByOthers('mail'); + await pruneAttachments(ctx); + let used = others + Number(ctx.db.get<{ n: number | null }>('SELECT SUM(size) AS n FROM blob')?.n ?? 0); for (;;) { if (ctx.stopped()) return; const parts = ctx.db.all<{ blob_id: string; size: number; type: string | null; name: string | null; received_at: number }>( diff --git a/lib/store/worker/host.ts b/lib/store/worker/host.ts index 9b56b594b..0620d05b3 100644 --- a/lib/store/worker/host.ts +++ b/lib/store/worker/host.ts @@ -12,6 +12,8 @@ import type { OpenAccountParams, StoreRpc, StoreStatus } from './protocol'; export interface HostStorage { open(file: string, keyHex: string): Promise; remove(file: string): boolean; + /** Database files in the pool. Their names say nothing about the accounts. */ + files(): string[]; } interface OpenAccount { @@ -44,17 +46,21 @@ export class StoreHost { constructor(private readonly options: StoreHostOptions) {} - async openAccount(params: OpenAccountParams): Promise { + /** Says whether the account's file was there: gone with a key still around, the browser cleared it. */ + async openAccount(params: OpenAccountParams): Promise<{ fileExisted: boolean }> { const existing = this.accounts.get(params.account); 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; + return { fileExisted: true }; } - const db = await this.options.storage.open(params.file, params.keyHex); + let fileExisted = this.options.storage.files().includes(params.file); + // Nothing to sync with yet: no empty file, or the next open would take it for the real one. + if (!fileExisted && !params.session) throw new Error('No session for this account yet'); + const db = await this.openOrReplace(params, () => { fileExisted = false; }); const session = params.session ?? AccountEngine.storedSession(db); if (!session) { db.close(); @@ -85,6 +91,32 @@ export class StoreHost { account.engine.setOnline(this.online); this.accounts.set(params.account, account); void account.engine.requestSync(); + return { fileExisted }; + } + + /** A file this key can't read is lost for good, whatever happened to it, and everything in it can be fetched again. */ + private async openOrReplace(params: OpenAccountParams, onReplaced: () => void): Promise { + try { + return await this.options.storage.open(params.file, params.keyHex); + } catch (error) { + if ((error as Error)?.name !== 'StoreKeyError' || !params.session) throw error; + this.options.storage.remove(params.file); + onReplaced(); + return this.options.storage.open(params.file, params.keyHex); + } + } + + /** Deletes files no account has a key for. Nothing can read them. Files in use stay, whatever the list says. */ + sweepFiles(known: readonly string[]): number { + const keep = new Set(known); + for (const open of this.accounts.values()) keep.add(open.file); + let removed = 0; + for (const name of this.options.storage.files()) { + const file = name.replace(/-journal$/, ''); + if (keep.has(file)) continue; + if (this.options.storage.remove(file)) removed += 1; + } + return removed; } setAuth(account: string, auth: string | null): void { @@ -104,8 +136,7 @@ export class StoreHost { setScope(account: string, scope: Parameters[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 { @@ -166,6 +197,12 @@ export class StoreHost { if (!open) throw accountClosed(); return open.engine.areaCall(name, callParams); } + case 'setScope': { + const { account, scope } = params as Parameters[0]; + if (!this.accounts.has(account)) throw accountClosed(); + this.setScope(account, scope); + return undefined; + } case 'removeAccount': { const { account, file } = params as Parameters[0]; return this.removeAccount(account, file); diff --git a/lib/store/worker/protocol.ts b/lib/store/worker/protocol.ts index eefd34f96..c20577d38 100644 --- a/lib/store/worker/protocol.ts +++ b/lib/store/worker/protocol.ts @@ -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 { @@ -75,10 +78,10 @@ export type PageToWorker = | { kind: 'init'; clientId: string; moduleUrl: string } | { kind: 'open'; id: string; params: OpenAccountParams } | { kind: 'close-account'; id: string; account: string } + | { kind: 'sweep-files'; id: string; known: string[] } | { kind: '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 = diff --git a/lib/store/worker/worker.ts b/lib/store/worker/worker.ts index 52e1c3688..d211fd622 100644 --- a/lib/store/worker/worker.ts +++ b/lib/store/worker/worker.ts @@ -110,8 +110,7 @@ scope.addEventListener('message', async (event) => { break; case 'open': try { - await requireHost().openAccount(message.params); - reply(message.id); + reply(message.id, await requireHost().openAccount(message.params)); } catch (error) { replyError(message.id, error); } @@ -124,15 +123,19 @@ scope.addEventListener('message', async (event) => { replyError(message.id, error); } break; + case 'sweep-files': + try { + reply(message.id, requireHost().sweepFiles(message.known)); + } catch (error) { + replyError(message.id, error); + } + break; case 'set-auth': host?.setAuth(message.account, message.auth); break; 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; From 22a2d9e3a6b7bccbf37cec6e451388a10c5a9a91 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:56:25 +0100 Subject: [PATCH 10/35] fix: keep subjects out of localStorage for accounts that keep mail encrypted --- .../__tests__/email-snapshot-account.test.ts | 49 +++++++++++++++++++ stores/__tests__/pro-tab-store.test.ts | 22 +++++++++ stores/email-store.ts | 19 ++++++- stores/pro-tab-store.ts | 22 ++++++++- 4 files changed, 110 insertions(+), 2 deletions(-) diff --git a/stores/__tests__/email-snapshot-account.test.ts b/stores/__tests__/email-snapshot-account.test.ts index a7d2c4e4f..0b31fc020 100644 --- a/stores/__tests__/email-snapshot-account.test.ts +++ b/stores/__tests__/email-snapshot-account.test.ts @@ -38,6 +38,55 @@ describe('email boot snapshot', { timeout: 30_000 }, () => { }); }); +// With the local store on, the mail is encrypted on the device, and subjects in +// localStorage would give it away. +describe('email boot snapshot with the local store on', { timeout: 30_000 }, () => { + const ROW = { id: 'e1', subject: 'Secret subject', mailboxIds: { inbox: true } }; + const SNAPSHOT = { v: 3, account: 'alice@mail.example', mailboxes: MAILBOXES, selectedMailbox: 'inbox', emails: [ROW] }; + + async function boot(enabled: string[]) { + persistSession('alice@mail.example'); + localStorage.setItem('email-snapshot', JSON.stringify(SNAPSHOT)); + localStorage.setItem('bulwark-offline-accounts', JSON.stringify(enabled)); + vi.resetModules(); + return (await import('@/stores/email-store')).useEmailStore; + } + + it('is removed at the start and not restored', async () => { + const store = await boot(['alice@mail.example']); + expect(localStorage.getItem('email-snapshot')).toBeNull(); + expect(store.getState().emails).toEqual([]); + }); + + it('still works for an account without it', async () => { + const store = await boot(['bob@mail.example']); + expect(localStorage.getItem('email-snapshot')).not.toBeNull(); + expect(store.getState().emails).toHaveLength(1); + }); + + it('is never written while it is on', async () => { + const store = await boot(['alice@mail.example']); + store.setState({ mailboxes: MAILBOXES as never, emails: [ROW] as never, selectedMailbox: 'inbox' }); + await new Promise((resolve) => setTimeout(resolve, 1100)); + expect(localStorage.getItem('email-snapshot')).toBeNull(); + }); + + it('is written without it, as the control', async () => { + const store = await boot([]); + store.setState({ emails: [{ ...ROW, id: 'e2' }] as never }); + await new Promise((resolve) => setTimeout(resolve, 1100)); + expect(localStorage.getItem('email-snapshot')).toContain('e2'); + }); + + it('goes the moment the user turns it on', async () => { + const store = await boot([]); + expect(store.getState().emails).toHaveLength(1); + const { useOfflineStore } = await import('@/lib/offline/state'); + useOfflineStore.setState({ enabled: ['alice@mail.example'] }); + expect(localStorage.getItem('email-snapshot')).toBeNull(); + }); +}); + // The snapshot keeps the last plain folder that was open; a session that // ended in a unified view left Trash there, and every visit to "/" opened it. describe('email boot snapshot folder', { timeout: 30_000 }, () => { diff --git a/stores/__tests__/pro-tab-store.test.ts b/stores/__tests__/pro-tab-store.test.ts index c473f08a3..6267629c5 100644 --- a/stores/__tests__/pro-tab-store.test.ts +++ b/stores/__tests__/pro-tab-store.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; +import { useOfflineStore } from '@/lib/offline/state'; import { useProTabStore, normalizeProTabState, @@ -507,3 +508,24 @@ describe('property: random operation sequences never violate the invariants', () } }); }); + +describe('stored tabs with the local store on', () => { + const stored = () => localStorage.getItem('pro-tabs') ?? ''; + const open = () => useProTabStore.getState().openEmailTab({ accountId: 'acc', emailId: 'm9', mailboxId: null, title: 'Secret subject' }); + + it('hold the subject without it', () => { + useOfflineStore.setState({ enabled: [] }); + open(); + expect(stored()).toContain('Secret subject'); + }); + + it('lose it the moment it is turned on, and the open tab keeps its title', () => { + useOfflineStore.setState({ enabled: [] }); + open(); + useOfflineStore.setState({ enabled: ['acc'] }); + expect(stored()).not.toContain('Secret subject'); + expect(stored()).toContain('m9'); + expect(useProTabStore.getState().tabs.some((tab) => tab.title === 'Secret subject')).toBe(true); + useOfflineStore.setState({ enabled: [] }); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 0f1b60161..a917bcfe0 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -38,6 +38,7 @@ import { SendCancelledCleanupError } from "@/lib/send-failure-message"; import type { StorageQuota } from "@/lib/quota"; import { isArchiveActionEnabled } from "@/lib/archive-gate"; import { getOutboxHooks } from "@/lib/offline/outbox-hooks"; +import { useOfflineStore } from "@/lib/offline/state"; import { READ_FILTER, UNREAD_FILTER, UNREAD_SECTION_SIZE, andFilters, isUnreadFirstView, mergeUnreadSection, openSectionRows, sectionFromUnreadRows, unreadSectionRowCount, withoutReached, type UnreadSection, @@ -8629,6 +8630,15 @@ const EMAIL_SNAPSHOT_KEY = 'email-snapshot'; const EMAIL_SNAPSHOT_VERSION = 3; const EMAIL_SNAPSHOT_MAX_EMAILS = 50; +/** + * With the local store on, the mail is encrypted on this device, and a copy of + * subjects in localStorage would give that away. The store paints the list at + * start itself (startLocalCopyClient), so the snapshot isn't needed there. + */ +function keptEncrypted(account: unknown): boolean { + return typeof account === 'string' && useOfflineStore.getState().enabled.includes(account); +} + function isEmailListSync(value: unknown, mailboxId: string): value is EmailListSync { const sync = value as EmailListSync | null; return !!sync && typeof sync.state === 'string' && typeof sync.accountId === 'string' && sync.mailboxId === mailboxId; @@ -8651,6 +8661,7 @@ if (typeof window !== 'undefined') { try { const auth = persistedAuthState(); const authed = auth?.isAuthenticated === true; + if (keptEncrypted(auth?.activeAccountId)) window.localStorage.removeItem(EMAIL_SNAPSHOT_KEY); const raw = authed ? window.localStorage.getItem(EMAIL_SNAPSHOT_KEY) : null; if (raw) { const snap = JSON.parse(raw); @@ -8698,6 +8709,12 @@ if (typeof window !== 'undefined') { } catch { /* corrupt snapshot - start empty */ } let snapshotTimer: ReturnType | null = null; + // Turned on just now: what's there goes at once. + useOfflineStore.subscribe((state, prev) => { + if (state.enabled === prev.enabled || !keptEncrypted(persistedAuthState()?.activeAccountId)) return; + if (snapshotTimer) { clearTimeout(snapshotTimer); snapshotTimer = null; } + try { window.localStorage.removeItem(EMAIL_SNAPSHOT_KEY); } catch { /* ignore */ } + }); useEmailStore.subscribe((state, prev) => { if ( state.emails === prev.emails && @@ -8727,7 +8744,7 @@ if (typeof window !== 'undefined') { // would be read as the active account's mail. (#847) if (s.searchQuery || s.selectedKeyword || s.isScheduledView || s.viewingAccountId || s.isUnifiedView) return; const account = persistedAuthState()?.activeAccountId; - if (typeof account !== 'string' || !account) return; + if (typeof account !== 'string' || !account || keptEncrypted(account)) return; try { window.localStorage.setItem(EMAIL_SNAPSHOT_KEY, JSON.stringify({ v: EMAIL_SNAPSHOT_VERSION, diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index 36051d2a8..c3f90fa99 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -4,6 +4,7 @@ import type { ComposerDraftData } from '@/components/email/email-composer'; import type { ComposePrefill } from '@/lib/plugin-compose-gate'; import type { EmailWriteSource } from '@/stores/email-store'; import type { SearchScope } from '@/lib/global-search/query-parser'; +import { useOfflineStore } from '@/lib/offline/state'; export type ProTabKind = | 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' @@ -122,6 +123,20 @@ export interface ProTab { paneId: ProPaneId; } +/** + * With the local store on, mail is encrypted on this device, and a subject in + * localStorage would give it away. The tab's body puts the title back when it + * has read the message. + */ +const TITLE_UNTIL_LOADED = '…'; + +function withoutSubjects(tabs: ProTab[]): ProTab[] { + if (useOfflineStore.getState().enabled.length === 0) return tabs; + return tabs.map((tab) => (tab.kind === 'email' && tab.emailData + ? { ...tab, title: TITLE_UNTIL_LOADED, emailData: { ...tab.emailData, title: TITLE_UNTIL_LOADED } } + : tab)); +} + export interface OpenEmailTabOptions { /** * Force the tab into a specific pane. Passing 'split' when no split exists @@ -695,7 +710,7 @@ export const useProTabStore = create()( // own draft-store already handles that. Persisted email tabs are fine to // restore (the tab body refetches the email by id). partialize: (state) => ({ - tabs: state.tabs.filter((tab) => tab.kind !== 'compose'), + tabs: withoutSubjects(state.tabs.filter((tab) => tab.kind !== 'compose')), activeTabId: state.activeTabId, activeSplitTabId: state.activeSplitTabId, focusedPaneId: state.focusedPaneId, @@ -742,3 +757,8 @@ export const useProTabStore = create()( }, ), ); + +// Turned on just now: the stored copy loses its subjects at once, not at the next tab change. +useOfflineStore.subscribe((state, prev) => { + if (state.enabled !== prev.enabled && state.enabled.length > 0) useProTabStore.setState({}); +}); From b001cbf109ce7ca2aa82e4f1d00c91622a21e79d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:57:41 +0100 Subject: [PATCH 11/35] feat: show what each account keeps on the device and let it be changed and cleared The offline section of Settings > Backup & reset now shows, per account, the space each part takes, the last sync, a first sync's progress with the time left, the window and attachment choices, and clearing the account. All conflicts, calendar and contacts too, are listed there and counted in the banner. - the organisation's caps beat the user's choice and the desktop default, and every path that takes a scope goes through `effectiveScope` - the desktop app keeps mail without asking, says so once, and stays off for an account where the user turned it off - `navigator.storage.persist()` is asked on turning it on, and a refusal warns with a way to ask again; Safari gets the 7-day hint when not installed - a store the browser deleted is noticed, says what was lost and syncs again - a full disk says so, with what to do - those who had offline mode keep it, with the 30 days they had - Keep offline in the Files app's menu --- .../admin/org/sections/offline-section.tsx | 2 +- components/files/file-browser.tsx | 12 +- .../__tests__/account-storage.test.tsx | 251 ++++++++++++++ components/offline/account-storage.tsx | 205 +++++++++++ components/offline/kept-files.tsx | 32 ++ components/offline/offline-banner.tsx | 2 +- components/offline/offline-host.tsx | 21 +- components/offline/offline-settings.tsx | 106 +++--- components/offline/outbox-list.tsx | 29 +- hooks/use-keep-file-offline.ts | 39 +++ hooks/use-kept-files.ts | 58 ++++ lib/__tests__/sign-out-cleanup.test.ts | 1 + .../__tests__/effective-scope-wiring.test.ts | 216 ++++++++++++ lib/offline/__tests__/migrate-legacy.test.ts | 24 +- lib/offline/__tests__/scope.test.ts | 122 +++++++ lib/offline/__tests__/storage-health.test.ts | 326 ++++++++++++++++++ lib/offline/__tests__/sync-progress.test.ts | 50 +++ lib/offline/migrate-legacy.ts | 5 +- lib/offline/offline-mode.ts | 274 +++++++++++++-- lib/offline/queue.ts | 3 +- lib/offline/scope.ts | 125 +++++++ lib/offline/state.ts | 117 +++++++ lib/offline/storage-health.ts | 60 ++++ lib/offline/sync-progress.ts | 60 ++++ lib/sign-out-cleanup.ts | 3 + locales/ar/common.json | 55 +++ locales/ca/common.json | 55 +++ locales/cs/common.json | 55 +++ locales/da/common.json | 55 +++ locales/de/common.json | 55 +++ locales/en/common.json | 55 +++ locales/es/common.json | 55 +++ locales/et/common.json | 55 +++ locales/fa/common.json | 55 +++ locales/fr/common.json | 55 +++ locales/he/common.json | 55 +++ locales/hu/common.json | 55 +++ locales/it/common.json | 55 +++ locales/ja/common.json | 55 +++ locales/ko/common.json | 55 +++ locales/lv/common.json | 55 +++ locales/mn/common.json | 55 +++ locales/nb/common.json | 55 +++ locales/nl/common.json | 55 +++ locales/pl/common.json | 55 +++ locales/pt/common.json | 55 +++ locales/ro/common.json | 55 +++ locales/ru/common.json | 55 +++ locales/sk/common.json | 55 +++ locales/th/common.json | 55 +++ locales/tr/common.json | 55 +++ locales/uk/common.json | 55 +++ locales/vi/common.json | 55 +++ locales/zh-TW/common.json | 55 +++ locales/zh/common.json | 55 +++ 55 files changed, 3701 insertions(+), 92 deletions(-) create mode 100644 components/offline/__tests__/account-storage.test.tsx create mode 100644 components/offline/account-storage.tsx create mode 100644 components/offline/kept-files.tsx create mode 100644 hooks/use-keep-file-offline.ts create mode 100644 hooks/use-kept-files.ts create mode 100644 lib/offline/__tests__/effective-scope-wiring.test.ts create mode 100644 lib/offline/__tests__/scope.test.ts create mode 100644 lib/offline/__tests__/storage-health.test.ts create mode 100644 lib/offline/__tests__/sync-progress.test.ts create mode 100644 lib/offline/scope.ts create mode 100644 lib/offline/storage-health.ts create mode 100644 lib/offline/sync-progress.ts diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx index 9494654ca..5c05dda77 100644 --- a/components/admin/org/sections/offline-section.tsx +++ b/components/admin/org/sections/offline-section.tsx @@ -25,7 +25,7 @@ const CAP_FIELDS: CapField[] = [ { key: 'offlineMaxAttachmentMb', label: 'Largest attachment or file kept', - description: 'Anything bigger is removed from the device and opened from the server when there is a connection.', + description: 'Anything bigger leaves the device and opens from the server when there is a connection. Files someone chose to keep offline count only towards the total below.', unit: 'MB', max: 1024 * 1024, }, diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 37d375816..d8bbc9683 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -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(null); const menu = useContextMenu(); + const tOffline = useTranslations("offlineStorage"); + const { keptOffline, canKeepOffline, toggleKeptOffline } = useKeepFileOffline(client); const fileInputRef = useRef(null); const folderInputRef = useRef(null); const searchInputRef = useRef(null); @@ -939,6 +942,13 @@ export function FileBrowser({ )} )} + {canKeepOffline(resource) && ( + { closeMenu(); void toggleKeptOffline(resource); }} + /> + )} {(canShare(resource) || (resource.isDirectory && !resource.isShared)) && } {canShare(resource) && ( { closeMenu(); setShareTargetId(resource.id); }} /> diff --git a/components/offline/__tests__/account-storage.test.tsx b/components/offline/__tests__/account-storage.test.tsx new file mode 100644 index 000000000..1e0aeb682 --- /dev/null +++ b/components/offline/__tests__/account-storage.test.tsx @@ -0,0 +1,251 @@ +import 'fake-indexeddb/auto'; +import { act, 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 () => {}), + chooseScope: vi.fn(), + requestPersistence: vi.fn(async () => 'refused'), + dismissAreaConflict: vi.fn(async () => {}), +})); +vi.mock('@/lib/offline/offline-mode', async (importOriginal) => ({ + ...(await importOriginal()), + clearAccount: offline.clearAccount, + 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 { 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) { + usePolicyStore.setState({ + loaded: true, source: 'server', subject: null, + 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(); + 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(); + 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(); + 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(); + 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(); + 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', { maxAttachmentMb: 5 }); + }); + + it('says when the total is set by the organisation', () => { + setDevices({ offlineMaxTotalMb: 100 }); + render(); + expect(screen.getByTestId('offline-total-locked').textContent).toContain('total_locked'); + }); + + it('shows nothing of this with offline off', () => { + useOfflineStore.setState({ enabled: [] }); + render(); + expect(screen.queryByText('window_label')).toBeNull(); + expect(screen.queryByTestId('offline-clear')).toBeNull(); + }); +}); + +describe('what it takes', () => { + it('lists each part', () => { + render(); + 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: { opened: true, lastSync: Date.now() - 60_000 } } }); + render(); + expect(screen.getByTestId('offline-status').textContent).toContain('usage_updated'); + }); + + 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(); + 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(); + expect(screen.queryByTestId('offline-progress')).toBeNull(); + }); +}); + +describe('clearing an account', () => { + it('asks first, naming unsent mail', () => { + useOfflineStore.setState({ outbox: [{ id: 1, account: 'acc', createdAt: 1, subject: 'Hi', to: [], status: 'waiting' }] }); + render(); + fireEvent.click(screen.getByTestId('offline-clear')); + expect(offline.clearAccount).not.toHaveBeenCalled(); + expect(screen.getByText(/clear_confirm_unsent/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'clear_confirm' })); + expect(offline.clearAccount).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(); + 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(); + expect(screen.queryByTestId('offline-persist-refused')).toBeNull(); + }); + + it('warns about a store it deleted, with the count, until read', () => { + useOfflineStore.setState({ health: { acc: { opened: true, evicted: { at: 1, lost: 2 } } } }); + render(); + 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.evicted).toBeUndefined(); + }); + + it('says the device is full, with what to do', () => { + useOfflineStore.setState({ storageFull: { acc: 1 } }); + render(); + 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(); + 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(); + 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(); + 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(); + expect(screen.getByTestId('offline-banner-conflicts')).toBeInTheDocument(); + }); +}); + +describe('files kept offline', () => { + it('lists the pinned ones with their state, and can let go of one', async () => { + render(); + 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('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' }); + }); +}); diff --git a/components/offline/account-storage.tsx b/components/offline/account-storage.tsx new file mode 100644 index 000000000..e3ebf9c73 --- /dev/null +++ b/components/offline/account-storage.tsx @@ -0,0 +1,205 @@ +'use client'; + +import { useEffect, 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, dismissEvicted, enableOffline, scopeForAccount } from '@/lib/offline/offline-mode'; +import { attachmentCapOptions, maxAttachmentMb, windowOptions } from '@/lib/offline/scope'; +import { useOfflineStore } from '@/lib/offline/state'; +import { minutesLeft, progressWorthShowing, type SyncProgress } from '@/lib/offline/sync-progress'; +import { devicesForAccount } from '@/lib/device-policy'; +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 ( +

+

+ {phase}: {count}{left !== null && <> · {t('progress_left', { minutes: left })}} +

+ {progress.total !== null && progress.total > 0 && ( + + )} +
+ ); +} + +/** 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 evicted = useOfflineStore((s) => s.health[account]?.evicted); + const storageFull = useOfflineStore((s) => !!s.storageFull[account]); + const progress = useOfflineStore((s) => s.progress[account]); + const unsent = useOfflineStore((s) => s.outbox.filter((row) => row.account === account).length); + // Re-renders on a choice; the value itself is worked out below, with the policy. + useOfflineStore((s) => s.choices[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 ( +
+ {heading &&

{heading}

} +

{t('keep_label')}

+

{t('forbidden')}

+ +
+ ); + } + + 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); + + return ( +
+ {heading &&

{heading}

} + + { + if (on) void enableOffline(account); + else if (unsent > 0) setConfirmOff(true); + else void disableOffline(account); + }} + /> + + {evicted && ( +
+

+ {tStorage('evicted')} + {evicted.lost > 0 && <> {tStorage('evicted_lost', { count: evicted.lost })}} +

+ +
+ )} + {enabled && ( + <> + {storageFull && ( +
+

{tStorage('storage_full_title')} {tStorage('storage_full_text')}

+ +
+ )} +
+

+ {syncing + ? t('syncing') + : lastSynced + ? t('usage_updated', { size: formatFileSize(usage?.bytes ?? 0), time: format.relativeTime(lastSynced, now) }) + : t('usage', { size: formatFileSize(usage?.bytes ?? 0) })} +

+ {syncing && progressWorthShowing(progress) && } + {usage && usage.parts.length > 0 && ( +
+ {usage.parts.map((part) => ( +
+
{tStorage(`usage_${part.part}`)}
+
{tStorage('usage_row', { size: formatFileSize(part.bytes), count: part.items })}
+
+ ))} +
+ )} +
+ +
+ chooseScope(account, { maxAttachmentMb: Number(value) })} + 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 && } +
+
+ )} + + + + + + )} + setConfirmOff(false)} + onConfirm={() => { setConfirmOff(false); void disableOffline(account); }} + title={t('off_confirm_title')} + message={t('off_confirm_message', { count: unsent })} + confirmText={t('off_confirm')} + variant="destructive" + /> + setConfirmClear(false)} + onConfirm={() => { setConfirmClear(false); void clearAccount(account); }} + title={tStorage('clear_confirm_title')} + message={unsent > 0 ? `${tStorage('clear_confirm_message')} ${tStorage('clear_confirm_unsent', { count: unsent })}` : tStorage('clear_confirm_message')} + confirmText={tStorage('clear_confirm')} + variant="destructive" + /> +
+ ); +} diff --git a/components/offline/kept-files.tsx b/components/offline/kept-files.tsx new file mode 100644 index 000000000..f38bf9059 --- /dev/null +++ b/components/offline/kept-files.tsx @@ -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 ( +
+

{t('kept_files_title')}

+
    + {pinned.map((file) => ( +
  • +
    +

    {file.name}

    +

    + {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')} +

    +
    + +
  • + ))} +
+
+ ); +} diff --git a/components/offline/offline-banner.tsx b/components/offline/offline-banner.tsx index dc179e362..2174bfb43 100644 --- a/components/offline/offline-banner.tsx +++ b/components/offline/offline-banner.tsx @@ -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 tConflicts = useTranslations('offlineConflicts'); const [open, setOpen] = useState(false); const offline = browserOffline || serverDown; diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx index 13e675950..b7657ce4d 100644 --- a/components/offline/offline-host.tsx +++ b/components/offline/offline-host.tsx @@ -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, type OfflineNotice } from '@/lib/offline/offline-mode'; import { useOfflineStore } from '@/lib/offline/state'; import { toast } from '@/stores/toast-store'; @@ -23,6 +25,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 { dialogProps, confirm } = useConfirmDialog(); const confirmRef = useRef(confirm); confirmRef.current = confirm; @@ -30,6 +38,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')); @@ -52,7 +61,17 @@ export function OfflineHost() { toast.warning(tConflictsRef.current('toast', { count: notice.count })); 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 'evicted': + toast.warning(tStorageRef.current('evicted'), { + message: notice.lost > 0 ? tStorageRef.current('evicted_lost', { count: notice.lost }) : undefined, + duration: notice.lost > 0 ? 0 : undefined, + action: openStorage, + }); + break; + case 'default-on': + toast.info(tStorageRef.current('default_on'), { duration: 0, action: openStorage }); break; case 'refused-delete': toast.warning(tr('toast.refused_delete')); diff --git a/components/offline/offline-settings.tsx b/components/offline/offline-settings.tsx index 013e37ecb..6205828d0 100644 --- a/components/offline/offline-settings.tsx +++ b/components/offline/offline-settings.tsx @@ -1,94 +1,84 @@ '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 { 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'; /** - * 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.filter((row) => row.account === account).length); - const [confirmOff, setConfirmOff] = useState(false); + useDevicePolicy(); + const labels = useAccountStore((s) => s.accounts); + const enabledCount = useOfflineStore((s) => s.enabled.length); + const persist = useOfflineStore((s) => s.persist); + 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 ( {!offlineSupported() ? (

{t('unsupported')}

- ) : !allowed ? ( -
-

{t('keep_label')}

-

{t('forbidden')}

- -
) : ( <> - - { - if (!account) return; - if (on) void enableOffline(account); - else if (unsent > 0) setConfirmOff(true); - else turnOff(); - }} - /> -
- {enabled && ( -

- {syncing - ? t('syncing') - : lastSynced - ? t('usage_updated', { size: formatFileSize(usage), time: format.relativeTime(lastSynced, Date.now()) }) - : t('usage', { size: formatFileSize(usage) })} -

+ {enabledCount > 0 && persist === 'refused' && ( +
+

{tStorage('persist_refused')}

+ +
)} + {enabledCount > 0 && safariMayDelete() && ( +

{tStorage('safari_hint')}

+ )} + {accounts.map((account, index) => ( + entry.id === account)?.email || labels.find((entry) => entry.id === account)?.label || account : null} + /> + ))} )} - setConfirmOff(false)} - onConfirm={() => { setConfirmOff(false); turnOff(); }} - title={t('off_confirm_title')} - message={t('off_confirm_message', { count: unsent })} - confirmText={t('off_confirm')} - variant="destructive" - />
); } diff --git a/components/offline/outbox-list.tsx b/components/offline/outbox-list.tsx index 7e8c3a864..37ef47d71 100644 --- a/components/offline/outbox-list.tsx +++ b/components/offline/outbox-list.tsx @@ -5,9 +5,9 @@ import { useFormatter, useTranslations } from 'next-intl'; 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, type ConflictEntry } 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'; /** @@ -22,10 +22,12 @@ export function OutboxList({ heading = true }: { heading?: boolean }) { const t = useTranslations('offline.outbox'); const tErrors = useTranslations('offline.errors'); const tConflicts = useTranslations('offlineConflicts'); + const tStorage = useTranslations('offlineStorage'); const format = useFormatter(); 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(null); const several = new Set(rows.map((r) => r.account)).size > 1 || accounts.length > 1; @@ -52,6 +54,12 @@ export function OutboxList({ heading = true }: { 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(); @@ -92,7 +100,7 @@ export function OutboxList({ heading = true }: { heading?: boolean }) { {changes > 0 && (

{t('changes', { count: changes })}

)} - {conflicts.length > 0 && ( + {conflicts.length + areaConflicts.length > 0 && (

{tConflicts('description')}

    @@ -109,6 +117,21 @@ export function OutboxList({ heading = true }: { heading?: boolean }) {
))} + {areaConflicts.map((conflict) => ( +
  • +
    +

    + {tStorage(conflict.area === 'calendar' ? 'conflict_calendar' : 'conflict_contacts')}: {conflict.title || t('no_subject')} +

    +

    + {areaConflictText(conflict)} · {format.dateTime(conflict.createdAt, { dateStyle: 'short', timeStyle: 'short' })} +

    +
    +
    + +
    +
  • + ))} )} diff --git a/hooks/use-keep-file-offline.ts b/hooks/use-keep-file-offline.ts new file mode 100644 index 000000000..aff9cd1cb --- /dev/null +++ b/hooks/use-keep-file-offline.ts @@ -0,0 +1,39 @@ +'use client'; + +import { useCallback, useMemo } from 'react'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +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 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]); + + const canKeepOffline = useCallback((resource: FileRef) => active && !!own && !resource.isDirectory && !resource.isSymlink, [active, own]); + + const toggleKeptOffline = useCallback(async (resource: FileRef) => { + const ref = fileRefFor(resource.id, own); + await (keptOffline.has(resource.id) ? unpin(ref) : pin(ref)).catch(() => {}); + }, [own, keptOffline, pin, unpin]); + + return { keptOffline, canKeepOffline, toggleKeptOffline }; +} diff --git a/hooks/use-kept-files.ts b/hooks/use-kept-files.ts new file mode 100644 index 000000000..3a706968f --- /dev/null +++ b/hooks/use-kept-files.ts @@ -0,0 +1,58 @@ +'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([]); + 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; + await getStore().area(account, 'files.unpin', ref); + await read(); + }, [account, read]); + + return { active, files, pinned, pin, unpin }; +} diff --git a/lib/__tests__/sign-out-cleanup.test.ts b/lib/__tests__/sign-out-cleanup.test.ts index 355f623e6..005865d76 100644 --- a/lib/__tests__/sign-out-cleanup.test.ts +++ b/lib/__tests__/sign-out-cleanup.test.ts @@ -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-choices', 'bulwark-offline-health', 'settings-storage', 'theme-storage', 'locale-storage', 'template-storage', 'files-view-mode', ]) localStorage.setItem(key, 'x'); diff --git a/lib/offline/__tests__/effective-scope-wiring.test.ts b/lib/offline/__tests__/effective-scope-wiring.test.ts new file mode 100644 index 000000000..d960f0d58 --- /dev/null +++ b/lib/offline/__tests__/effective-scope-wiring.test.ts @@ -0,0 +1,216 @@ +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, + registerOfflineHooks, + scopeForAccount, + setOfflineNotifier, + wipeEverything, + type OfflineNotice, +} from '../offline-mode'; +import { useOfflineStore, writeEnabled } 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): void { + usePolicyStore.setState({ + loaded: true, source: 'server', subject: null, 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('a policy fetch', () => { + async function kept(): Promise { + 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-choices')!).acc.days).toBe(30); + }); + + 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('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?.opened).toBe(false); + // It isn't read as a browser clearing the store at the next open. + expect(notices.filter((n) => n.kind === 'evicted')).toEqual([]); + }); +}); + +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); + }); + + it('holds to a cap from the first open', async () => { + asDesktop(); + setDevices({ offlineMaxDays: 14 }); + enforcePolicy(); + await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual(['acc'])); + expect(scopeForAccount('acc').scope.days).toBe(14); + }); +}); + +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']); + }); +}); diff --git a/lib/offline/__tests__/migrate-legacy.test.ts b/lib/offline/__tests__/migrate-legacy.test.ts index 37e3fa492..532f8ba4d 100644 --- a/lib/offline/__tests__/migrate-legacy.test.ts +++ b/lib/offline/__tests__/migrate-legacy.test.ts @@ -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_CHOICES_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((resolve) => { const request = indexedDB.deleteDatabase(LEGACY_DB_NAME); request.onsuccess = () => resolve(); @@ -156,6 +159,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_CHOICES_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); diff --git a/lib/offline/__tests__/scope.test.ts b/lib/offline/__tests__/scope.test.ts new file mode 100644 index 000000000..2531d97d4 --- /dev/null +++ b/lib/offline/__tests__/scope.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it } 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 { 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, maxAttachmentMb: 5 }, NO_LIMITS, BROWSER); + expect(effective.enabled).toBe(true); + expect(effective.scope).toEqual({ ...DEFAULT_SCOPE, days: 30, attachments: false, maxAttachmentBytes: 5 * MB }); + expect(effectiveScope('a', { on: false }, NO_LIMITS, DESKTOP).enabled).toBe(false); + 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, maxAttachmentMb: 500 }, policy, DESKTOP); + expect(capped.scope).toEqual({ days: 60, attachments: true, maxAttachmentBytes: 10 * MB, maxTotalBytes: 100 * MB }); + expect(capped.locked).toMatchObject({ days: true, maxAttachment: true, maxTotal: true, attachments: false }); + expect(effectiveScope('a', null, { ...policy, offlineMaxDays: 30 }, DESKTOP).scope.days).toBe(30); + const under = effectiveScope('a', { days: 30, maxAttachmentMb: 5 }, policy, BROWSER); + 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('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, and has no store to apply them to', 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 }); + }); +}); + +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); + }); +}); diff --git a/lib/offline/__tests__/storage-health.test.ts b/lib/offline/__tests__/storage-health.test.ts new file mode 100644 index 000000000..86a85ae61 --- /dev/null +++ b/lib/offline/__tests__/storage-health.test.ts @@ -0,0 +1,326 @@ +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, wasEvicted } from '../storage-health'; +import { OFFLINE_CHOICES_KEY, OFFLINE_HEALTH_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(); + 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. */ + static fileExisted = true; + onmessage: ((event: MessageEvent) => void) | null = null; + posted: Array> = []; + constructor() { + FakeWorker.all.push(this); + } + postMessage(message: Record): void { + this.posted.push(message); + if (message.kind === 'open') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: { fileExisted: FakeWorker.fileExisted } } } as MessageEvent)); + if (message.kind === 'sweep-files') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: 0 } } as MessageEvent)); + } + 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): 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.fileExisted = true; + rpcs.length = 0; + notices.length = 0; + vi.stubGlobal('BroadcastChannel', FakeChannel); + vi.stubGlobal('Worker', FakeWorker); + vi.stubGlobal('FileSystemFileHandle', class {}); + setStorage({ getDirectory: async () => ({ removeEntry: async () => {} }), persist: async () => true, persisted: async () => true }); + Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { request: async (_name: string, fn: () => unknown) => fn() } }); + useOfflineStore.setState({ + enabled: [], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usage: {}, usageBytes: 0, choices: {}, health: {}, + storageFull: {}, progress: {}, persist: 'unknown', syncing: [], lastSyncedAt: {}, browserOffline: false, downServers: [], startedOffline: false, + }); + usePolicyStore.setState({ loaded: true, source: 'server', subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never); + setStoreBackend(null); + 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 { + 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', () => { + it('a key with no file: warns, with how many waiting items went', async () => { + await openAccountKey('acc'); + writeHealth('acc', { opened: true, pending: 3 }); + FakeWorker.fileExisted = false; + await openAsOwner('acc'); + await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'evicted', lost: 3 })); + expect(useOfflineStore.getState().health.acc.evicted?.lost).toBe(3); + expect(useOfflineStore.getState().health.acc.pending).toBe(0); + }); + + it('no key where one was made: warns too', async () => { + writeHealth('acc', { opened: true, pending: 0 }); + FakeWorker.fileExisted = false; + await openAsOwner('acc'); + await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'evicted', lost: 0 })); + }); + + it('a first open is not an eviction', async () => { + FakeWorker.fileExisted = false; + await openAsOwner('acc'); + await vi.waitFor(() => expect(useOfflineStore.getState().health.acc?.opened).toBe(true)); + expect(notices.filter((n) => n.kind === 'evicted')).toEqual([]); + }); + + it('a normal start is not one either', async () => { + await openAccountKey('acc'); + writeHealth('acc', { opened: true, pending: 3 }); + await openAsOwner('acc'); + await vi.waitFor(() => expect(FakeWorker.all[0].posted.some((m) => m.kind === 'open')).toBe(true)); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(notices).toEqual([]); + }); + + it('files with no key are swept once this tab owns the store', async () => { + const { record } = await openAccountKey('acc'); + const worker = await openAsOwner('acc'); + await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'sweep-files')).toBe(true)); + expect((worker.posted.find((m) => m.kind === 'sweep-files') as { known: string[] }).known).toEqual([record.file]); + }); + + it('wasEvicted', () => { + expect(wasEvicted({ opened: true, hadKey: true, fileExisted: false })).toBe(true); + expect(wasEvicted({ opened: false, hadKey: true, fileExisted: false })).toBe(true); + expect(wasEvicted({ opened: true, hadKey: false, fileExisted: false })).toBe(true); + expect(wasEvicted({ opened: false, hadKey: false, fileExisted: false })).toBe(false); + expect(wasEvicted({ opened: true, hadKey: true, fileExisted: true })).toBe(false); + }); + + it('keeps only numbers about it outside the store', () => { + writeHealth('acc', { opened: true, pending: 2, evicted: { at: 5, lost: 2 }, lastSync: 9 }); + expect(JSON.parse(localStorage.getItem(OFFLINE_HEALTH_KEY)!)).toEqual({ acc: { opened: true, pending: 2, evicted: { at: 5, lost: 2 }, lastSync: 9 } }); + }); +}); + +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', { opened: true, 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_CHOICES_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_CHOICES_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('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', { opened: true, pending: 2 }); + + purgeSignedOutData(); + + await vi.waitFor(() => expect(removeEntry).toHaveBeenCalledWith('.bulwark-store', { recursive: true })); + await vi.waitFor(async () => expect(await getAccountRecord('acc')).toBeNull()); + for (const key of ['bulwark-offline-accounts', OFFLINE_CHOICES_KEY, OFFLINE_HEALTH_KEY]) expect(localStorage.getItem(key)).toBeNull(); + expect(useOfflineStore.getState().choices).toEqual({}); + expect(useOfflineStore.getState().health).toEqual({}); + }); +}); diff --git a/lib/offline/__tests__/sync-progress.test.ts b/lib/offline/__tests__/sync-progress.test.ts new file mode 100644 index 000000000..6bba589c9 --- /dev/null +++ b/lib/offline/__tests__/sync-progress.test.ts @@ -0,0 +1,50 @@ +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('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); + }); +}); diff --git a/lib/offline/migrate-legacy.ts b/lib/offline/migrate-legacy.ts index d9a6c0253..9be4c2d8f 100644 --- a/lib/offline/migrate-legacy.ts +++ b/lib/offline/migrate-legacy.ts @@ -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'; @@ -172,6 +173,8 @@ async function moveNow(): Promise { 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); diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 1993f154d..9ccda7694 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -21,13 +21,13 @@ */ import { IS_LITE } from '@/lib/lite'; -import { devicesForAccount } from '@/lib/device-policy'; +import { devicesForAccount, 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 { configureStore, getStore, type StorePageHooks } from '@/lib/store/client/api'; import { purgeStoreFiles } from '@/lib/store/client/purge'; import type { StoreSession } from '@/lib/store/engine/transport'; @@ -52,7 +52,30 @@ import { type PolicyFingerprint, } from './queue'; import { clearHandoffs } from '@/lib/launch-handoff'; -import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state'; +import { + forgetAccountState, + forgetAllAccountState, + isOfflineNow, + OFFLINE_ACCOUNTS_KEY, + OFFLINE_CHOICES_KEY, + OFFLINE_HEALTH_KEY, + readEnabledFrom, + readStoredChoices, + readStoredHealth, + serverKey, + setServerDown, + useOfflineStore, + 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, wasEvicted } from './storage-health'; +import { nextProgress } from './sync-progress'; import { flushQueue, settleStaleSends } from './flush'; import { LEGACY_DB_NAME, moveLegacyOutbox } from './migrate-legacy'; import { deleteShellCaches, tellServiceWorker } from './shell'; @@ -66,6 +89,8 @@ export type OfflineNotice = | { kind: 'lost'; subjects: string[] } | { kind: 'conflict'; count: number } | { kind: 'storage-failed' } + | { kind: 'evicted'; lost: number } + | { kind: 'default-on' } | { kind: 'refused-delete' } | { kind: 'refused-other' } | { kind: 'forbidden' } @@ -107,6 +132,11 @@ export function offlineAllowed(account: string | null | undefined): boolean { return offlineSupported() && !devicesForAccount(account ?? null).noOffline; } +/** 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), devicesForAccount(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); @@ -145,6 +175,9 @@ 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, }; }, @@ -155,12 +188,34 @@ const storeHooks: StorePageHooks = { await client.requestNetworkOnly([['Core/echo', {}, '0']]); return client.getAuthHeader(); }, + onOpened(account, found) { + const health = useOfflineStore.getState().health[account]; + if (wasEvicted({ opened: !!health?.opened, ...found })) { + const lost = health?.pending ?? 0; + writeHealth(account, { opened: true, pending: 0, evicted: { at: Date.now(), lost } }); + notify({ kind: 'evicted', lost }); + void refreshQueueState(); + return; + } + writeHealth(account, { opened: true }); + }, 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 }); 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; @@ -194,6 +249,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>(); /** A black-holed connection never answers the probe either. */ const PROBE_TIMEOUT_MS = 5_000; @@ -238,7 +296,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(); } @@ -270,7 +328,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 }>).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; 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(); @@ -471,13 +533,13 @@ export async function refreshQueueState(): Promise { // Nothing is kept for an account that hasn't it on; and opening the // store would create it (on the sign-in page, after a sign-out). if (useOfflineStore.getState().enabled.length === 0) { - useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [] }); + useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [] }); return; } try { // Sends of a tab closed on the way: uncertain, so they can be looked up. await settleStaleSends(); - const { patches, outbox: entries, conflicts } = await readQueue(); + const { patches, patchesBy, outbox: entries, conflicts } = await readQueue(); const rows: OutboxRow[] = entries.map((entry) => ({ id: entry.id, account: entry.account, @@ -487,17 +549,72 @@ export async function refreshQueueState(): Promise { status: entry.status, error: entry.error, })); - useOfflineStore.setState({ outbox: rows, pendingChanges: patches, conflicts }); + const { enabled } = useOfflineStore.getState(); + const areas = await Promise.all(enabled.map(readAreaState)); + useOfflineStore.setState({ outbox: rows, pendingChanges: patches, conflicts, areaConflicts: areas.flatMap((area) => area.conflicts) }); + // What a browser clearing the store would lose. Counted here, and the + // count is all that's kept outside the store. + enabled.forEach((account, index) => { + const sends = rows.filter((row) => row.account === account).length; + writeHealth(account, { pending: sends + (patchesBy[account] ?? 0) + areas[index].pending }); + }); } catch { // The store is being deleted, or can't start. } } +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 = (name: string, none: T) => getStore().area(account, name).then((value) => value ?? none, () => none); + const [calendar, contacts, calendarPending, contactsPending] = await Promise.all([ + read('calendar.conflicts', []), + read('contacts.conflicts', []), + read('calendar.pending', 0), + read('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 { + await getStore().area(conflict.account, `${conflict.area}.dismissConflict`, { id: conflict.id }); + await refreshQueueState(); +} + export async function refreshUsage(): Promise { - if (!offlineSupported() || useOfflineStore.getState().enabled.length === 0) return; + const { enabled } = useOfflineStore.getState(); + if (!offlineSupported() || enabled.length === 0) return; try { - const usage = await Promise.all(useOfflineStore.getState().enabled.map((account) => getStore().area<{ bytes: number }>(account, 'mail.usage'))); - useOfflineStore.setState({ usageBytes: usage.reduce((sum, { bytes }) => sum + bytes, 0) }); + const usage = await Promise.all(enabled.map((account) => getStore().area(account, 'store.usage'))); + useOfflineStore.setState({ + usageBytes: usage.reduce((sum, { bytes }) => sum + bytes, 0), + usage: Object.fromEntries(enabled.map((account, index) => [account, usage[index]])), + }); } catch { /* deleted meanwhile */ } @@ -586,6 +703,7 @@ async function flushAreas(account: string, client: NonNullable(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; const role = (useAuthStore.getState().client as unknown) === client ? 'active' : 'background'; const changed = { [client.getAccountId()]: Object.fromEntries(types.map((type) => [type, 'flushed'])) }; @@ -632,13 +750,12 @@ export async function flushAll(): Promise { export async function enableOffline(account: string): Promise { 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); @@ -648,10 +765,94 @@ export async function enableOffline(account: string): Promise { export async function disableOffline(account: string): Promise { 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(); + +/** + * 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 { + 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. + } + writeHealth(account, { opened: false, pending: 0, evicted: undefined, lastSync: undefined }); + 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 { + writeChoice(account, patch); + pushScope(account); +} + +/** The store's scope follows the page's answer. Nothing happens in the store if it's the same as before. */ +function pushScope(account: string): void { + if (!useOfflineStore.getState().enabled.includes(account) || !scopeDecided(account) || !offlineActive(account)) return; + 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 evicted warning was read. */ +export function dismissEvicted(account: string): void { + writeHealth(account, { evicted: undefined }); +} + +export { readPersistence, requestPersistence, safariMayDelete }; + // ---- Unsent mail a wipe deletes ---- // The user agreed to lose unsent mail (the sign-out question, switching @@ -706,6 +907,7 @@ export async function wipeAccount(account: string): Promise { 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) { @@ -726,6 +928,7 @@ export async function wipeEverything(): Promise { for (const timer of syncTimers.values()) clearTimeout(timer); syncTimers.clear(); writeEnabled([]); + forgetAllAccountState(); getStore().stop(); await purgeStoreFiles(); try { @@ -738,7 +941,13 @@ export async function wipeEverything(): Promise { clearHandoffs(); confirmed.clear(); named.clear(); - useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], usageBytes: 0, syncing: [], lastSyncedAt: {} }); + useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], 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 } = usePolicyStore.getState(); + return loaded && (source === 'server' || source === 'cache') && policyDecided(account); } /** Whether the policy of an account is known well enough to decide on it. */ @@ -761,8 +970,12 @@ export function enforcePolicy(): void { clearHandoffs(); void wipeAccount(account); notify({ kind: 'forbidden' }); + continue; } + // A lowered cap prunes what's kept, and a raised one starts fetching. + pushScope(account); } + applyDefaultOn(); } // ---- Started without the server ---- @@ -852,6 +1065,8 @@ export function startOfflineMode(): () => void { // there): stop here too, and don't write anything for that account. One // switched on there has to be opened here if this tab owns the store. const onStorage = (event: StorageEvent) => { + if (event.key === OFFLINE_CHOICES_KEY || event.key === null) useOfflineStore.setState({ choices: readStoredChoices() }); + if (event.key === OFFLINE_HEALTH_KEY || event.key === null) useOfflineStore.setState({ health: readStoredHealth() }); if (event.key !== OFFLINE_ACCOUNTS_KEY && event.key !== null) return; const next = readEnabledFrom(event.key === null ? null : event.newValue); const before = useOfflineStore.getState().enabled; @@ -885,6 +1100,7 @@ export function startOfflineMode(): () => void { // Session, then a sync, for every account signed in with offline mode on. const syncSignedIn = () => { + applyDefaultOn(); for (const account of useOfflineStore.getState().enabled) { const client = onlineClientFor(account); if (!client || !offlineActive(account)) continue; @@ -906,7 +1122,16 @@ export function startOfflineMode(): () => void { clearInterval(reconnect); }); - 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(); @@ -934,7 +1159,10 @@ onFullSignOut(() => { getStore().stop(); confirmed.clear(); named.clear(); - useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, conflicts: [], usageBytes: 0, syncing: [], lastSyncedAt: {}, startedOffline: false }); + useOfflineStore.setState({ + enabled: [], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usageBytes: 0, usage: {}, choices: {}, health: {}, + storageFull: {}, progress: {}, syncing: [], lastSyncedAt: {}, startedOffline: false, + }); }); // Every sign-out the user didn't agree to lose mail at (the session limits, diff --git a/lib/offline/queue.ts b/lib/offline/queue.ts index 5686f1fc5..f709431eb 100644 --- a/lib/offline/queue.ts +++ b/lib/offline/queue.ts @@ -39,11 +39,12 @@ interface StoredQueue { } /** Everything waiting for one account (or every account with it on), oldest first. */ -export async function readQueue(account?: string): Promise<{ patches: number; outbox: OutboxEntry[]; conflicts: ConflictEntry[] }> { +export async function readQueue(account?: string): Promise<{ patches: number; patchesBy: Record; outbox: OutboxEntry[]; conflicts: ConflictEntry[] }> { const accounts = account ? [account] : useOfflineStore.getState().enabled; const read = await Promise.all(accounts.map(async (id) => ({ id, queue: await getStore().area(id, 'outbox.read') }))); return { 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, diff --git a/lib/offline/scope.ts b/lib/offline/scope.ts new file mode 100644 index 000000000..5398a0161 --- /dev/null +++ b/lib/offline/scope.ts @@ -0,0 +1,125 @@ +/** + * 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 { 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, maxAttachmentMb: 5 }; + +export interface PlatformDefault { + on: boolean; + 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; + days: boolean; + attachments: boolean; + maxAttachment: boolean; + maxTotal: boolean; + }; +} + +/** The desktop app keeps mail without asking. Browsers stay opt-in. Feature-detected: the bridge is only there in the app. */ +export function offlineDefaultOn(): boolean { + return typeof window !== 'undefined' && Boolean((window as { bulwarkDesktop?: unknown }).bulwarkDesktop); +} + +export function platformDefault(): PlatformDefault { + return { on: offlineDefaultOn(), 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, + defaults: PlatformDefault, +): EffectiveScope { + const choice = userChoice ?? {}; + const days = choice.days !== undefined ? choice.days : defaults.scope.days; + const maxAttachment = choice.maxAttachmentMb !== undefined ? choice.maxAttachmentMb * MB : defaults.scope.maxAttachmentBytes; + return { + account, + enabled: !policy.noOffline && (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), + }, + locked: { + offline: policy.noOffline, + 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; +} diff --git a/lib/offline/state.ts b/lib/offline/state.ts index 1ba184282..fe2969d97 100644 --- a/lib/offline/state.ts +++ b/lib/offline/state.ts @@ -7,10 +7,50 @@ */ import { create } from 'zustand'; +import type { StoreUsage } from '@/lib/store/area'; import type { ConflictEntry, OutboxEntry, OutboxError } from './queue'; +import type { PersistState } from './storage-health'; +import type { SyncProgress } from './sync-progress'; /** localStorage: the local account ids that keep mail on this device. */ export const OFFLINE_ACCOUNTS_KEY = 'bulwark-offline-accounts'; +/** localStorage: what the user picked per account. Numbers and switches only. */ +export const OFFLINE_CHOICES_KEY = 'bulwark-offline-choices'; +/** localStorage: what'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 { + /** Off here means the user turned it off: the desktop app's default doesn't turn it back on. */ + on?: boolean; + /** Days of mail to keep. Null keeps everything. */ + days?: number | null; + attachments?: boolean; + maxAttachmentMb?: number; +} + +export interface AccountHealth { + /** The store was opened for this account once. */ + opened?: boolean; + /** Unsent messages and changes last counted: what an eviction loses. */ + pending?: number; + /** When the last sync finished. Survives a reload, unlike the running state. */ + lastSync?: number; + /** The browser deleted what was kept. Stays until the user has read it. */ + evicted?: { at: number; lost: number }; +} + +/** 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; @@ -35,7 +75,18 @@ 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[]; usageBytes: number; + usage: Record; + choices: Record; + health: Record; + /** 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; + /** How far the sync running for an account got. */ + progress: Record; /** Accounts whose mail is being brought onto the device right now. */ syncing: string[]; lastSyncedAt: Record; @@ -51,6 +102,25 @@ function readEnabled(): string[] { } } +function readRecord(key: string): Record { + 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 : {}; + } catch { + return {}; + } +} + +function writeRecord(key: string, value: Record): 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()(() => ({ enabled: readEnabled(), browserOffline: typeof navigator !== 'undefined' && navigator.onLine === false, @@ -59,11 +129,58 @@ export const useOfflineStore = create()(() => ({ outbox: [], pendingChanges: 0, conflicts: [], + areaConflicts: [], usageBytes: 0, + usage: {}, + choices: readRecord(OFFLINE_CHOICES_KEY), + health: readRecord(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_CHOICES_KEY, choices); +} + +export function forgetAccountState(account: string): void { + const { choices, health, usage, storageFull, progress } = useOfflineStore.getState(); + const without = (record: Record) => Object.fromEntries(Object.entries(record).filter(([id]) => id !== account)); + const next = { choices: without(choices), health: without(health), usage: without(usage), storageFull: without(storageFull), progress: without(progress) }; + useOfflineStore.setState(next); + writeRecord(OFFLINE_CHOICES_KEY, next.choices); + writeRecord(OFFLINE_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 { + return readRecord(OFFLINE_CHOICES_KEY); +} + +export function readStoredHealth(): Record { + return readRecord(OFFLINE_HEALTH_KEY); +} + +/** Nothing is kept for anyone any more. */ +export function forgetAllAccountState(): void { + useOfflineStore.setState({ choices: {}, health: {}, usage: {}, storageFull: {}, progress: {}, areaConflicts: [] }); + writeRecord(OFFLINE_CHOICES_KEY, {}); + writeRecord(OFFLINE_HEALTH_KEY, {}); +} + export function writeEnabled(enabled: string[]): void { useOfflineStore.setState({ enabled }); try { diff --git a/lib/offline/storage-health.ts b/lib/offline/storage-health.ts new file mode 100644 index 000000000..6205be58d --- /dev/null +++ b/lib/offline/storage-health.ts @@ -0,0 +1,60 @@ +/** + * 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 { + 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 { + 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); +} + +/** + * Whether the browser took away what was kept for an account: its key record + * with no file, or no key where one was made. A file with no key can't be read + * by anyone and is swept without a word; if the account had been opened, that + * is the second case. On a first open neither applies. + */ +export function wasEvicted(state: { opened: boolean; hadKey: boolean; fileExisted: boolean }): boolean { + return state.hadKey ? !state.fileExisted : state.opened; +} diff --git a/lib/offline/sync-progress.ts b/lib/offline/sync-progress.ts new file mode 100644 index 000000000..01c1db2e5 --- /dev/null +++ b/lib/offline/sync-progress.ts @@ -0,0 +1,60 @@ +/** + * 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; +/** 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; + 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)); +} diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 9496e43d5..17534302b 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -36,6 +36,9 @@ const SIGNED_OUT_STORAGE_KEYS = [ // Offline mode's "Keep mail on this device" (lib/offline/state.ts): the // next person signing in here starts with it off. 'bulwark-offline-accounts', + // What each account chose to keep, and what's known about its store. + 'bulwark-offline-choices', + 'bulwark-offline-health', ]; const SIGNED_OUT_SIGNAL = 'bulwark:signed-out'; diff --git a/locales/ar/common.json b/locales/ar/common.json index ae8e0aa58..d27a9b2c9 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5756,6 +5756,61 @@ "toast": "تغييرات أُجريت دون اتصال ولم تُطبَّق: {count}. يوضح صندوق الصادر أيها.", "storage_failed": "لم تعد هناك مساحة على هذا الجهاز للبريد دون اتصال. أفرغ مساحة أو احتفظ بأقل من الإعدادات." }, + "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": "سيُحذف كل ما يحتفظ به هذا الجهاز لهذا الحساب ثم يُنزَّل من جديد. يبقى بريدك على الخادم.", + "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} دون اتصال" + }, "deep_link": { "message_not_found": "لم تعد هذه الرسالة متاحة.", "thread_not_found": "لم تعد هذه المحادثة متاحة.", diff --git a/locales/ca/common.json b/locales/ca/common.json index ce62318b7..e9c57770f 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5756,6 +5756,61 @@ "toast": "Canvis fets sense connexió que no s'han aplicat: {count}. La safata de sortida indica quins.", "storage_failed": "Aquest dispositiu no té més espai per al correu sense connexió. Allibereu espai o conserveu-ne menys a la configuració." }, + "offlineStorage": { + "keep_description": "Desa el vostre correu, calendari, contactes i els fitxers que trieu, xifrats en aquest navegador. 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 d'aquest compte 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ó" + }, "deep_link": { "message_not_found": "Aquest missatge ja no està disponible.", "thread_not_found": "Aquesta conversa ja no està disponible.", diff --git a/locales/cs/common.json b/locales/cs/common.json index a37357845..da3998c40 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5756,6 +5756,61 @@ "toast": "Nepoužité změny provedené offline: {count}. Které to jsou, ukazuje pošta k odeslání.", "storage_failed": "Na tomto zařízení už není místo pro poštu offline. Uvolněte místo nebo v nastavení ponechte méně." }, + "offlineStorage": { + "keep_description": "Uchovává vaši poštu, kalendář, kontakty a vybrané soubory zašifrované v tomto prohlížeči. Offline můžete poštu číst, označovat a přesouvat a to, co napíšete, čeká v odesílané poště. Odhlášení vše odstraní.", + "window_label": "Jak dlouho do minulosti uchovávat", + "window_description": "Starší pošta a události z kalendáře toto zařízení opustí. Když budete online, můžete je stále otevřít.", + "window_days": "{days, plural, one {# den} few {# dny} many {# dne} other {# dní}}", + "window_year": "1 rok", + "window_everything": "Vše", + "attachments_label": "Uchovávat přílohy", + "attachments_description": "Přílohy uchovaných zpráv a soubory, které uchováváte offline, celkem až {total}. Nejstarší se odstraní jako první.", + "total_locked": "Vaše organizace povoluje nejvýše {size} příloh a souborů.", + "attachment_cap_label": "Největší uchovávaná příloha", + "usage_mail": "Zprávy a index vyhledávání", + "usage_attachments": "Přílohy", + "usage_calendar": "Kalendář", + "usage_contacts": "Kontakty", + "usage_files": "Soubory uchované offline", + "usage_row": "{size} ({count, plural, one {# položka} few {# položky} many {# položky} other {# položek}})", + "progress_list": "Vypisují se zprávy", + "progress_bodies": "Stahují se zprávy", + "progress_threads": "Řadí se konverzace", + "progress_files": "Stahují se soubory", + "progress_other": "Přenášejí se data do tohoto zařízení", + "progress_count": "{done} z {total}", + "progress_count_open": "zatím {done}", + "progress_left": "zbývá asi {minutes, plural, one {# minuta} few {# minuty} many {# minuty} other {# minut}}", + "clear_label": "Vymazat tento účet", + "clear_description": "Smaže vše, co toto zařízení pro tento účet uchovává, a stáhne to znovu.", + "clear_button": "Vymazat…", + "clear_confirm_title": "Vymazat tento účet na tomto zařízení?", + "clear_confirm_message": "Vše, co toto zařízení pro tento účet uchovává, se smaže a stáhne znovu. Vaše pošta na serveru zůstane.", + "clear_confirm_unsent": "{count, plural, one {# neodeslaná zpráva se ztratí.} few {# neodeslané zprávy se ztratí.} many {# neodeslané zprávy se ztratí.} other {# neodeslaných zpráv se ztratí.}}", + "clear_confirm": "Vymazat", + "persist_refused": "Tento prohlížeč může uchovanou poštu smazat, když dojde místo. Instalace aplikace tomu obvykle zabrání.", + "persist_retry": "Zeptat se znovu", + "safari_hint": "Safari smaže data webu po 7 dnech bez návštěvy. Přidejte Bulwark na plochu nebo do Docku, aby vaše pošta na tomto zařízení zůstala.", + "evicted": "Prohlížeč smazal poštu uchovanou na tomto zařízení. Stahuje se znovu.", + "evicted_lost": "{count, plural, one {# neodeslaná zpráva nebo změna se ztratila.} few {# neodeslané zprávy nebo změny se ztratily.} many {# neodeslané zprávy nebo změny se ztratily.} other {# neodeslaných zpráv nebo změn se ztratilo.}}", + "evicted_dismiss": "OK", + "storage_full_title": "Úložiště zařízení je plné.", + "storage_full_text": "Bulwark sem přestal ukládat poštu. Uchovávejte méně pošty nebo vymažte tento účet, aby se uvolnilo místo.", + "open_settings": "Otevřít nastavení", + "default_on": "Bulwark uchovává vaši poštu na tomto zařízení, aby fungoval i bez připojení. Můžete to změnit v Nastavení.", + "conflict_calendar": "Událost v kalendáři", + "conflict_contacts": "Kontakt", + "kept_files_title": "Soubory uchované offline", + "kept_state_kept": "Na tomto zařízení", + "kept_state_waiting": "Čeká na stažení", + "kept_error_tooLarge": "Příliš velký k uchování", + "kept_error_overLimit": "Přes limit tohoto zařízení", + "kept_error_notAllowed": "Vaše organizace uchovávání souborů nepovoluje", + "kept_error_download": "Nepodařilo se stáhnout", + "keep_offline": "Uchovat offline", + "unpin": "Přestat uchovávat offline", + "unpin_label": "Přestat uchovávat {name} offline" + }, "deep_link": { "message_not_found": "Tato zpráva už není dostupná.", "thread_not_found": "Tato konverzace už není dostupná.", diff --git a/locales/da/common.json b/locales/da/common.json index b024c725c..7ecfa2e82 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5756,6 +5756,61 @@ "toast": "Ændringer lavet offline, der ikke blev gennemført: {count}. Udbakken viser hvilke.", "storage_failed": "Denne enhed har ikke mere plads til offlinemail. Frigør plads, eller behold mindre i indstillingerne." }, + "offlineStorage": { + "keep_description": "Beholder din mail, kalender, dine kontakter og de filer, du vælger, krypteret i denne browser. Offline kan du læse, markere og flytte mail, og det, du skriver, venter i udbakken. Når du logger ud, fjernes det hele.", + "window_label": "Hvor langt tilbage der beholdes", + "window_description": "Ældre mail og kalenderbegivenheder forlader denne enhed. Du kan stadig åbne dem, når du har forbindelse.", + "window_days": "{days, plural, one {# dag} other {# dage}}", + "window_year": "1 år", + "window_everything": "Alt", + "attachments_label": "Behold vedhæftede filer", + "attachments_description": "Vedhæftede filer fra beholdte beskeder og de filer, du beholder offline, op til {total} i alt. De ældste fjernes først.", + "total_locked": "Din organisation tillader højst {size} vedhæftede filer og filer.", + "attachment_cap_label": "Største vedhæftede fil, der beholdes", + "usage_mail": "Beskeder og søgeindeks", + "usage_attachments": "Vedhæftede filer", + "usage_calendar": "Kalender", + "usage_contacts": "Kontakter", + "usage_files": "Filer beholdt offline", + "usage_row": "{size} ({count, plural, one {# element} other {# elementer}})", + "progress_list": "Lister beskeder", + "progress_bodies": "Henter beskeder", + "progress_threads": "Sorterer samtaler", + "progress_files": "Henter filer", + "progress_other": "Henter data til denne enhed", + "progress_count": "{done} af {total}", + "progress_count_open": "{done} indtil nu", + "progress_left": "cirka {minutes, plural, one {# minut} other {# minutter}} tilbage", + "clear_label": "Ryd denne konto", + "clear_description": "Sletter alt, denne enhed beholder for denne konto, og henter det derefter igen.", + "clear_button": "Ryd…", + "clear_confirm_title": "Ryd denne konto på denne enhed?", + "clear_confirm_message": "Alt, denne enhed beholder for denne konto, slettes og hentes igen. Din mail på serveren bliver liggende.", + "clear_confirm_unsent": "{count, plural, one {# usendt besked går tabt.} other {# usendte beskeder går tabt.}}", + "clear_confirm": "Ryd", + "persist_refused": "Denne browser kan slette den beholdte mail, når pladsen bliver knap. Det hjælper normalt at installere appen.", + "persist_retry": "Spørg igen", + "safari_hint": "Safari sletter et websteds data efter 7 dage uden besøg. Føj Bulwark til din hjemmeskærm eller til Dock for at beholde din mail på denne enhed.", + "evicted": "Browseren slettede den mail, der var beholdt på denne enhed. Den hentes igen.", + "evicted_lost": "{count, plural, one {# usendt besked eller ændring gik tabt.} other {# usendte beskeder eller ændringer gik tabt.}}", + "evicted_dismiss": "OK", + "storage_full_title": "Enhedens lager er fuldt.", + "storage_full_text": "Bulwark er holdt op med at gemme mail her. Behold mindre mail, eller ryd denne konto for at frigøre plads.", + "open_settings": "Åbn indstillinger", + "default_on": "Bulwark beholder din mail på denne enhed, så den virker uden forbindelse. Du kan ændre det i Indstillinger.", + "conflict_calendar": "Kalenderbegivenhed", + "conflict_contacts": "Kontakt", + "kept_files_title": "Filer beholdt offline", + "kept_state_kept": "På denne enhed", + "kept_state_waiting": "Venter på download", + "kept_error_tooLarge": "For stor til at beholde", + "kept_error_overLimit": "Over grænsen for denne enhed", + "kept_error_notAllowed": "Din organisation tillader ikke, at filer beholdes", + "kept_error_download": "Kunne ikke hentes", + "keep_offline": "Behold offline", + "unpin": "Behold ikke længere offline", + "unpin_label": "Behold ikke {name} offline længere" + }, "deep_link": { "message_not_found": "Denne besked er ikke længere tilgængelig.", "thread_not_found": "Denne samtale er ikke længere tilgængelig.", diff --git a/locales/de/common.json b/locales/de/common.json index 2dc411640..001c5ba3b 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5756,6 +5756,61 @@ "toast": "Offline gemachte Änderungen wurden nicht übernommen: {count}. Welche, steht im Postausgang.", "storage_failed": "Auf diesem Gerät ist kein Platz mehr für Offline-E-Mails. Geben Sie Speicher frei oder behalten Sie in den Einstellungen weniger." }, + "offlineStorage": { + "keep_description": "Behält Ihre E-Mails, Ihren Kalender, Ihre Kontakte und ausgewählte Dateien verschlüsselt in diesem Browser. Offline können Sie E-Mails lesen, markieren und verschieben, und was Sie schreiben, wartet im Postausgang. Beim Abmelden wird alles entfernt.", + "window_label": "Wie weit zurück behalten", + "window_description": "Ältere E-Mails und Kalendertermine werden von diesem Gerät entfernt. Sie können sie weiterhin öffnen, solange Sie online sind.", + "window_days": "{days, plural, one {# Tag} other {# Tage}}", + "window_year": "1 Jahr", + "window_everything": "Alles", + "attachments_label": "Anhänge behalten", + "attachments_description": "Anhänge behaltener Nachrichten und die Dateien, die Sie offline behalten, insgesamt bis zu {total}. Die ältesten werden zuerst entfernt.", + "total_locked": "Ihre Organisation erlaubt höchstens {size} für Anhänge und Dateien.", + "attachment_cap_label": "Größter Anhang, der behalten wird", + "usage_mail": "Nachrichten und Suchindex", + "usage_attachments": "Anhänge", + "usage_calendar": "Kalender", + "usage_contacts": "Kontakte", + "usage_files": "Offline behaltene Dateien", + "usage_row": "{size} ({count, plural, one {# Element} other {# Elemente}})", + "progress_list": "Nachrichten werden aufgelistet", + "progress_bodies": "Nachrichten werden geladen", + "progress_threads": "Unterhaltungen werden sortiert", + "progress_files": "Dateien werden geladen", + "progress_other": "Daten werden auf dieses Gerät geholt", + "progress_count": "{done} von {total}", + "progress_count_open": "bisher {done}", + "progress_left": "noch etwa {minutes, plural, one {# Minute} other {# Minuten}}", + "clear_label": "Dieses Konto leeren", + "clear_description": "Löscht alles, was dieses Gerät für dieses Konto behält, und lädt es danach neu.", + "clear_button": "Leeren…", + "clear_confirm_title": "Dieses Konto auf diesem Gerät leeren?", + "clear_confirm_message": "Alles, was dieses Gerät für dieses Konto behält, wird gelöscht und neu geladen. Ihre E-Mails auf dem Server bleiben erhalten.", + "clear_confirm_unsent": "{count, plural, one {# ungesendete Nachricht geht verloren.} other {# ungesendete Nachrichten gehen verloren.}}", + "clear_confirm": "Leeren", + "persist_refused": "Dieser Browser kann die behaltenen E-Mails löschen, wenn der Platz knapp wird. Die App zu installieren verhindert das meistens.", + "persist_retry": "Erneut fragen", + "safari_hint": "Safari löscht die Daten einer Website nach 7 Tagen ohne Besuch. Fügen Sie Bulwark zum Home-Bildschirm oder zum Dock hinzu, damit Ihre E-Mails auf diesem Gerät bleiben.", + "evicted": "Der Browser hat die auf diesem Gerät behaltenen E-Mails gelöscht. Sie werden neu geladen.", + "evicted_lost": "{count, plural, one {# ungesendete Nachricht oder Änderung ging verloren.} other {# ungesendete Nachrichten oder Änderungen gingen verloren.}}", + "evicted_dismiss": "OK", + "storage_full_title": "Der Speicher des Geräts ist voll.", + "storage_full_text": "Bulwark speichert hier keine E-Mails mehr. Behalten Sie weniger E-Mails oder leeren Sie dieses Konto, um Platz zu schaffen.", + "open_settings": "Einstellungen öffnen", + "default_on": "Bulwark behält Ihre E-Mails auf diesem Gerät, damit es auch ohne Verbindung funktioniert. Sie können das in den Einstellungen ändern.", + "conflict_calendar": "Kalendertermin", + "conflict_contacts": "Kontakt", + "kept_files_title": "Offline behaltene Dateien", + "kept_state_kept": "Auf diesem Gerät", + "kept_state_waiting": "Wartet auf den Download", + "kept_error_tooLarge": "Zu groß zum Behalten", + "kept_error_overLimit": "Über dem Limit für dieses Gerät", + "kept_error_notAllowed": "Ihre Organisation erlaubt das Behalten von Dateien nicht", + "kept_error_download": "Konnte nicht geladen werden", + "keep_offline": "Offline behalten", + "unpin": "Nicht mehr offline behalten", + "unpin_label": "{name} nicht mehr offline behalten" + }, "deep_link": { "message_not_found": "Diese Nachricht ist nicht mehr verfügbar.", "thread_not_found": "Diese Unterhaltung ist nicht mehr verfügbar.", diff --git a/locales/en/common.json b/locales/en/common.json index fdb1eac13..2093b56e6 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5756,6 +5756,61 @@ "toast": "Changes you made offline weren't applied: {count}. The outbox shows which.", "storage_failed": "This device has no room left for offline mail. Free up space, or keep less in Settings." }, + "offlineStorage": { + "keep_description": "Keeps your mail, calendar, contacts and chosen files encrypted in this browser. Offline you can read, flag and move mail, and what you write waits in the outbox. Signing out removes all of it.", + "window_label": "How far back to keep", + "window_description": "Older mail and calendar events leave this device. You can still open them when you have a connection.", + "window_days": "{days, plural, one {# day} other {# days}}", + "window_year": "1 year", + "window_everything": "Everything", + "attachments_label": "Keep attachments", + "attachments_description": "Attachments of kept messages and the files you keep offline, up to {total} in all. The oldest go first.", + "total_locked": "Your organisation allows {size} of attachments and files at most.", + "attachment_cap_label": "Largest attachment to keep", + "usage_mail": "Messages and search index", + "usage_attachments": "Attachments", + "usage_calendar": "Calendar", + "usage_contacts": "Contacts", + "usage_files": "Files kept offline", + "usage_row": "{size} ({count, plural, one {# item} other {# items}})", + "progress_list": "Listing messages", + "progress_bodies": "Downloading messages", + "progress_threads": "Sorting conversations", + "progress_files": "Downloading files", + "progress_other": "Bringing data to this device", + "progress_count": "{done} of {total}", + "progress_count_open": "{done} so far", + "progress_left": "about {minutes, plural, one {# minute} other {# minutes}} left", + "clear_label": "Clear this account", + "clear_description": "Deletes everything this device keeps for this account, then downloads it again.", + "clear_button": "Clear…", + "clear_confirm_title": "Clear this account on this device?", + "clear_confirm_message": "Everything this device keeps for this account is deleted and downloaded again. Your mail on the server stays.", + "clear_confirm_unsent": "{count, plural, one {# unsent message is lost.} other {# unsent messages are lost.}}", + "clear_confirm": "Clear", + "persist_refused": "This browser may delete the kept mail when space runs low. Installing the app usually stops that.", + "persist_retry": "Ask again", + "safari_hint": "Safari deletes a site's data after 7 days without a visit. Add Bulwark to your Home Screen or Dock to keep your mail on this device.", + "evicted": "The browser deleted the mail kept on this device. It's downloading again.", + "evicted_lost": "{count, plural, one {# unsent message or change was lost.} other {# unsent messages or changes were lost.}}", + "evicted_dismiss": "OK", + "storage_full_title": "Device storage is full.", + "storage_full_text": "Bulwark has stopped saving mail here. Keep less mail or clear this account to make room.", + "open_settings": "Open settings", + "default_on": "Bulwark keeps your mail on this device, so it works without a connection. You can change this in Settings.", + "conflict_calendar": "Calendar event", + "conflict_contacts": "Contact", + "kept_files_title": "Files kept offline", + "kept_state_kept": "On this device", + "kept_state_waiting": "Waiting to download", + "kept_error_tooLarge": "Too large to keep", + "kept_error_overLimit": "Over the limit for this device", + "kept_error_notAllowed": "Your organisation doesn't allow keeping files", + "kept_error_download": "Couldn't be downloaded", + "keep_offline": "Keep offline", + "unpin": "Stop keeping offline", + "unpin_label": "Stop keeping {name} offline" + }, "deep_link": { "message_not_found": "This message is no longer available.", "thread_not_found": "This conversation is no longer available.", diff --git a/locales/es/common.json b/locales/es/common.json index 7b90943c5..c2246b552 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5756,6 +5756,61 @@ "toast": "Cambios hechos sin conexión que no se aplicaron: {count}. La bandeja de salida indica cuáles.", "storage_failed": "Este dispositivo no tiene espacio para el correo sin conexión. Libera espacio o guarda menos en Ajustes." }, + "offlineStorage": { + "keep_description": "Guarda tu correo, tu calendario, tus contactos y los archivos que elijas, cifrados en este navegador. Sin conexión puedes leer, marcar y mover correo, y lo que escribas espera en la bandeja de salida. Al cerrar sesión se borra todo.", + "window_label": "Cuánto tiempo atrás guardar", + "window_description": "El correo y los eventos más antiguos salen de este dispositivo. Aún puedes abrirlos cuando tengas conexión.", + "window_days": "{days, plural, one {# día} other {# días}}", + "window_year": "1 año", + "window_everything": "Todo", + "attachments_label": "Guardar adjuntos", + "attachments_description": "Adjuntos de los mensajes guardados y archivos que guardas sin conexión, hasta {total} en total. Los más antiguos se eliminan primero.", + "total_locked": "Tu organización permite como máximo {size} de adjuntos y archivos.", + "attachment_cap_label": "Adjunto más grande que se guarda", + "usage_mail": "Mensajes e índice de búsqueda", + "usage_attachments": "Adjuntos", + "usage_calendar": "Calendario", + "usage_contacts": "Contactos", + "usage_files": "Archivos guardados sin conexión", + "usage_row": "{size} ({count, plural, one {# elemento} other {# elementos}})", + "progress_list": "Listando mensajes", + "progress_bodies": "Descargando mensajes", + "progress_threads": "Ordenando conversaciones", + "progress_files": "Descargando archivos", + "progress_other": "Trayendo datos a este dispositivo", + "progress_count": "{done} de {total}", + "progress_count_open": "{done} hasta ahora", + "progress_left": "faltan unos {minutes, plural, one {# minuto} other {# minutos}}", + "clear_label": "Vaciar esta cuenta", + "clear_description": "Elimina todo lo que este dispositivo guarda de esta cuenta y lo vuelve a descargar.", + "clear_button": "Vaciar…", + "clear_confirm_title": "¿Vaciar esta cuenta en este dispositivo?", + "clear_confirm_message": "Todo lo que este dispositivo guarda de esta cuenta se elimina y se vuelve a descargar. Tu correo en el servidor se mantiene.", + "clear_confirm_unsent": "{count, plural, one {Se perderá # mensaje sin enviar.} other {Se perderán # mensajes sin enviar.}}", + "clear_confirm": "Vaciar", + "persist_refused": "Este navegador puede borrar el correo guardado cuando falte espacio. Instalar la aplicación suele evitarlo.", + "persist_retry": "Preguntar de nuevo", + "safari_hint": "Safari borra los datos de un sitio tras 7 días sin visitarlo. Añade Bulwark a tu pantalla de inicio o al Dock para conservar tu correo en este dispositivo.", + "evicted": "El navegador borró el correo guardado en este dispositivo. Se está descargando de nuevo.", + "evicted_lost": "{count, plural, one {Se perdió # mensaje o cambio sin enviar.} other {Se perdieron # mensajes o cambios sin enviar.}}", + "evicted_dismiss": "OK", + "storage_full_title": "El almacenamiento del dispositivo está lleno.", + "storage_full_text": "Bulwark ha dejado de guardar correo aquí. Guarda menos correo o vacía esta cuenta para liberar espacio.", + "open_settings": "Abrir ajustes", + "default_on": "Bulwark guarda tu correo en este dispositivo para que funcione sin conexión. Puedes cambiarlo en Ajustes.", + "conflict_calendar": "Evento del calendario", + "conflict_contacts": "Contacto", + "kept_files_title": "Archivos guardados sin conexión", + "kept_state_kept": "En este dispositivo", + "kept_state_waiting": "Esperando la descarga", + "kept_error_tooLarge": "Demasiado grande para guardarlo", + "kept_error_overLimit": "Supera el límite de este dispositivo", + "kept_error_notAllowed": "Tu organización no permite guardar archivos", + "kept_error_download": "No se pudo descargar", + "keep_offline": "Guardar sin conexión", + "unpin": "Dejar de guardar sin conexión", + "unpin_label": "Dejar de guardar {name} sin conexión" + }, "deep_link": { "message_not_found": "Este mensaje ya no está disponible.", "thread_not_found": "Esta conversación ya no está disponible.", diff --git a/locales/et/common.json b/locales/et/common.json index a6b5b5eb2..289a223d8 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5756,6 +5756,61 @@ "toast": "Võrguühenduseta tehtud rakendamata muudatused: {count}. Väljundkaust näitab, millised.", "storage_failed": "Selles seadmes pole enam ruumi võrguühenduseta kirjade jaoks. Vabastage ruumi või hoidke seadetes vähem." }, + "offlineStorage": { + "keep_description": "Hoiab sinu kirju, kalendrit, kontakte ja valitud faile krüptitult selles brauseris. Võrguta saad kirju lugeda, märgistada ja teisaldada ning see, mida kirjutad, ootab väljuvate kirjade kastis. Välja logides eemaldatakse kõik.", + "window_label": "Kui kaugele minevikku hoida", + "window_description": "Vanemad kirjad ja kalendrisündmused kaovad sellest seadmest. Saad neid endiselt avada, kui oled võrgus.", + "window_days": "{days, plural, one {# päev} other {# päeva}}", + "window_year": "1 aasta", + "window_everything": "Kõik", + "attachments_label": "Hoia manuseid", + "attachments_description": "Hoitud kirjade manused ja failid, mida hoiad võrguta, kokku kuni {total}. Vanimad eemaldatakse esimesena.", + "total_locked": "Sinu organisatsioon lubab manuseid ja faile kokku kuni {size}.", + "attachment_cap_label": "Suurim hoitav manus", + "usage_mail": "Kirjad ja otsinguindeks", + "usage_attachments": "Manused", + "usage_calendar": "Kalender", + "usage_contacts": "Kontaktid", + "usage_files": "Võrguta hoitud failid", + "usage_row": "{size} ({count, plural, one {# kirje} other {# kirjet}})", + "progress_list": "Kirjade loendamine", + "progress_bodies": "Kirjade allalaadimine", + "progress_threads": "Vestluste sortimine", + "progress_files": "Failide allalaadimine", + "progress_other": "Andmete toomine sellesse seadmesse", + "progress_count": "{done} / {total}", + "progress_count_open": "seni {done}", + "progress_left": "jäänud umbes {minutes, plural, one {# minut} other {# minutit}}", + "clear_label": "Tühjenda see konto", + "clear_description": "Kustutab kõik, mida see seade selle konto jaoks hoiab, ja laadib selle uuesti alla.", + "clear_button": "Tühjenda…", + "clear_confirm_title": "Kas tühjendada see konto selles seadmes?", + "clear_confirm_message": "Kõik, mida see seade selle konto jaoks hoiab, kustutatakse ja laaditakse uuesti alla. Sinu kirjad serveris jäävad alles.", + "clear_confirm_unsent": "{count, plural, one {# saatmata kiri läheb kaotsi.} other {# saatmata kirja lähevad kaotsi.}}", + "clear_confirm": "Tühjenda", + "persist_refused": "See brauser võib hoitud kirjad kustutada, kui ruumi napib. Rakenduse paigaldamine hoiab selle tavaliselt ära.", + "persist_retry": "Küsi uuesti", + "safari_hint": "Safari kustutab saidi andmed, kui seda pole 7 päeva külastatud. Lisa Bulwark avaekraanile või Dockile, et su kirjad sellesse seadmesse alles jääksid.", + "evicted": "Brauser kustutas selles seadmes hoitud kirjad. Need laaditakse uuesti alla.", + "evicted_lost": "{count, plural, one {# saatmata kiri või muudatus läks kaotsi.} other {# saatmata kirja või muudatust läks kaotsi.}}", + "evicted_dismiss": "OK", + "storage_full_title": "Seadme salvestusruum on täis.", + "storage_full_text": "Bulwark ei salvesta siin enam kirju. Hoia vähem kirju või tühjenda see konto, et ruumi vabastada.", + "open_settings": "Ava seaded", + "default_on": "Bulwark hoiab su kirju selles seadmes, et see töötaks ka ilma ühenduseta. Saad seda seadetes muuta.", + "conflict_calendar": "Kalendrisündmus", + "conflict_contacts": "Kontakt", + "kept_files_title": "Võrguta hoitud failid", + "kept_state_kept": "Selles seadmes", + "kept_state_waiting": "Ootab allalaadimist", + "kept_error_tooLarge": "Liiga suur hoidmiseks", + "kept_error_overLimit": "Ületab selle seadme piiri", + "kept_error_notAllowed": "Sinu organisatsioon ei luba faile hoida", + "kept_error_download": "Allalaadimine ebaõnnestus", + "keep_offline": "Hoia võrguta", + "unpin": "Ära hoia enam võrguta", + "unpin_label": "Ära hoia faili {name} enam võrguta" + }, "deep_link": { "message_not_found": "See sõnum ei ole enam saadaval.", "thread_not_found": "See vestlus ei ole enam saadaval.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 270c6c3a8..0e1056d21 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5756,6 +5756,61 @@ "toast": "تغییرات آفلاین که اعمال نشدند: {count}. صندوق خروجی نشان می‌دهد کدام‌ها.", "storage_failed": "در این دستگاه جایی برای ایمیل آفلاین نمانده است. فضا را آزاد کنید یا در تنظیمات مقدار کمتری نگه دارید." }, + "offlineStorage": { + "keep_description": "ایمیل‌ها، تقویم، مخاطبان و فایل‌هایی را که انتخاب می‌کنید به‌صورت رمزگذاری‌شده در این مرورگر نگه می‌دارد. در حالت آفلاین می‌توانید ایمیل‌ها را بخوانید، پرچم بزنید و جابه‌جا کنید، و آنچه می‌نویسید در صندوق خروجی منتظر می‌ماند. با خروج از حساب همه پاک می‌شود.", + "window_label": "تا چه مدت قبل نگه داشته شود", + "window_description": "ایمیل‌ها و رویدادهای تقویم قدیمی‌تر از این دستگاه حذف می‌شوند. وقتی اتصال دارید همچنان می‌توانید آن‌ها را باز کنید.", + "window_days": "{days, plural, one {# روز} 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, one {# مورد} other {# مورد}})", + "progress_list": "در حال فهرست‌کردن پیام‌ها", + "progress_bodies": "در حال دانلود پیام‌ها", + "progress_threads": "در حال مرتب‌سازی گفتگوها", + "progress_files": "در حال دانلود فایل‌ها", + "progress_other": "در حال آوردن داده‌ها به این دستگاه", + "progress_count": "{done} از {total}", + "progress_count_open": "تا اینجا {done}", + "progress_left": "حدود {minutes, plural, one {# دقیقه} other {# دقیقه}} مانده", + "clear_label": "پاک‌کردن این حساب", + "clear_description": "هر چه این دستگاه برای این حساب نگه می‌دارد حذف می‌کند و دوباره دانلود می‌کند.", + "clear_button": "پاک‌کردن…", + "clear_confirm_title": "این حساب روی این دستگاه پاک شود؟", + "clear_confirm_message": "هر چه این دستگاه برای این حساب نگه می‌دارد حذف و دوباره دانلود می‌شود. ایمیل‌های شما روی سرور می‌مانند.", + "clear_confirm_unsent": "{count, plural, one {# پیام ارسال‌نشده از بین می‌رود.} other {# پیام ارسال‌نشده از بین می‌روند.}}", + "clear_confirm": "پاک‌کردن", + "persist_refused": "وقتی فضا کم شود، این مرورگر ممکن است ایمیل‌های نگه‌داشته‌شده را حذف کند. نصب برنامه معمولاً جلوی این را می‌گیرد.", + "persist_retry": "دوباره بپرس", + "safari_hint": "Safari داده‌های یک سایت را پس از ۷ روز بدون بازدید حذف می‌کند. Bulwark را به صفحه اصلی یا Dock اضافه کنید تا ایمیل‌هایتان روی این دستگاه بماند.", + "evicted": "مرورگر ایمیل‌های نگه‌داشته‌شده روی این دستگاه را حذف کرد. دوباره دانلود می‌شوند.", + "evicted_lost": "{count, plural, one {# پیام یا تغییر ارسال‌نشده از بین رفت.} 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} دیگر آفلاین نگه داشته نشود" + }, "deep_link": { "message_not_found": "این پیام دیگر در دسترس نیست.", "thread_not_found": "این گفتگو دیگر در دسترس نیست.", diff --git a/locales/fr/common.json b/locales/fr/common.json index dae94c9c7..f1e6d1484 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5756,6 +5756,61 @@ "toast": "Des modifications faites hors ligne n'ont pas été appliquées : {count}. La boîte d'envoi indique lesquelles.", "storage_failed": "Cet appareil n'a plus de place pour le courrier hors ligne. Libérez de l'espace ou conservez moins dans les paramètres." }, + "offlineStorage": { + "keep_description": "Garde vos e-mails, votre agenda, vos contacts et les fichiers que vous choisissez, chiffrés dans ce navigateur. Hors ligne, vous pouvez lire, signaler et déplacer des e-mails, et ce que vous écrivez attend dans la boîte d'envoi. La déconnexion supprime tout.", + "window_label": "Durée de conservation", + "window_description": "Les e-mails et événements plus anciens quittent cet appareil. Vous pouvez toujours les ouvrir quand vous êtes connecté.", + "window_days": "{days, plural, one {# jour} other {# jours}}", + "window_year": "1 an", + "window_everything": "Tout", + "attachments_label": "Garder les pièces jointes", + "attachments_description": "Pièces jointes des messages gardés et fichiers que vous gardez hors ligne, jusqu'à {total} au total. Les plus anciens partent en premier.", + "total_locked": "Votre organisation autorise au maximum {size} de pièces jointes et de fichiers.", + "attachment_cap_label": "Plus grande pièce jointe à garder", + "usage_mail": "Messages et index de recherche", + "usage_attachments": "Pièces jointes", + "usage_calendar": "Agenda", + "usage_contacts": "Contacts", + "usage_files": "Fichiers gardés hors ligne", + "usage_row": "{size} ({count, plural, one {# élément} other {# éléments}})", + "progress_list": "Liste des messages", + "progress_bodies": "Téléchargement des messages", + "progress_threads": "Tri des conversations", + "progress_files": "Téléchargement des fichiers", + "progress_other": "Transfert des données vers cet appareil", + "progress_count": "{done} sur {total}", + "progress_count_open": "{done} jusqu'ici", + "progress_left": "environ {minutes, plural, one {# minute} other {# minutes}} restantes", + "clear_label": "Vider ce compte", + "clear_description": "Supprime tout ce que cet appareil garde pour ce compte, puis le télécharge de nouveau.", + "clear_button": "Vider…", + "clear_confirm_title": "Vider ce compte sur cet appareil ?", + "clear_confirm_message": "Tout ce que cet appareil garde pour ce compte est supprimé puis téléchargé de nouveau. Vos e-mails sur le serveur restent en place.", + "clear_confirm_unsent": "{count, plural, one {# message non envoyé sera perdu.} other {# messages non envoyés seront perdus.}}", + "clear_confirm": "Vider", + "persist_refused": "Ce navigateur peut supprimer les e-mails gardés quand la place manque. Installer l'application l'évite en général.", + "persist_retry": "Redemander", + "safari_hint": "Safari supprime les données d'un site après 7 jours sans visite. Ajoutez Bulwark à votre écran d'accueil ou au Dock pour garder vos e-mails sur cet appareil.", + "evicted": "Le navigateur a supprimé les e-mails gardés sur cet appareil. Ils sont de nouveau en cours de téléchargement.", + "evicted_lost": "{count, plural, one {# message ou modification non envoyé a été perdu.} other {# messages ou modifications non envoyés ont été perdus.}}", + "evicted_dismiss": "OK", + "storage_full_title": "Le stockage de l'appareil est plein.", + "storage_full_text": "Bulwark n'enregistre plus d'e-mails ici. Gardez moins d'e-mails ou videz ce compte pour libérer de la place.", + "open_settings": "Ouvrir les réglages", + "default_on": "Bulwark garde vos e-mails sur cet appareil pour fonctionner sans connexion. Vous pouvez le modifier dans les réglages.", + "conflict_calendar": "Événement de l'agenda", + "conflict_contacts": "Contact", + "kept_files_title": "Fichiers gardés hors ligne", + "kept_state_kept": "Sur cet appareil", + "kept_state_waiting": "En attente de téléchargement", + "kept_error_tooLarge": "Trop volumineux pour être gardé", + "kept_error_overLimit": "Limite de cet appareil dépassée", + "kept_error_notAllowed": "Votre organisation n'autorise pas de garder des fichiers", + "kept_error_download": "Téléchargement impossible", + "keep_offline": "Garder hors ligne", + "unpin": "Ne plus garder hors ligne", + "unpin_label": "Ne plus garder {name} hors ligne" + }, "deep_link": { "message_not_found": "Ce message n'est plus disponible.", "thread_not_found": "Cette conversation n'est plus disponible.", diff --git a/locales/he/common.json b/locales/he/common.json index 6053b7fc7..9ff456a21 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5757,6 +5757,61 @@ "toast": "שינויים במצב לא מקוון שלא הוחלו: {count}. דואר יוצא מראה אילו.", "storage_failed": "אין יותר מקום במכשיר הזה לדואר לא מקוון. יש לפנות מקום או לשמור פחות בהגדרות." }, + "offlineStorage": { + "keep_description": "שומר את הדואר, היומן, אנשי הקשר והקבצים שבחרת, מוצפנים בדפדפן הזה. במצב לא מקוון אפשר לקרוא, לסמן ולהעביר דואר, ומה שכותבים ממתין בתיבת הדואר היוצא. התנתקות מוחקת הכול.", + "window_label": "כמה זמן אחורה לשמור", + "window_description": "דואר ואירועי יומן ישנים יותר יוסרו מהמכשיר הזה. אפשר עדיין לפתוח אותם כשיש חיבור.", + "window_days": "{days, plural, one {יום #} two {יומיים} 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, one {פריט #} two {# פריטים} other {# פריטים}})", + "progress_list": "מציג רשימת הודעות", + "progress_bodies": "מוריד הודעות", + "progress_threads": "ממיין שיחות", + "progress_files": "מוריד קבצים", + "progress_other": "מביא נתונים למכשיר הזה", + "progress_count": "{done} מתוך {total}", + "progress_count_open": "עד כה {done}", + "progress_left": "נותרו בערך {minutes, plural, one {דקה #} two {# דקות} other {# דקות}}", + "clear_label": "ניקוי החשבון הזה", + "clear_description": "מוחק את כל מה שהמכשיר הזה שומר עבור החשבון הזה, ואז מוריד אותו מחדש.", + "clear_button": "ניקוי…", + "clear_confirm_title": "לנקות את החשבון הזה במכשיר הזה?", + "clear_confirm_message": "כל מה שהמכשיר הזה שומר עבור החשבון הזה יימחק ויורד מחדש. הדואר שלך בשרת נשאר.", + "clear_confirm_unsent": "{count, plural, one {הודעה אחת שלא נשלחה תאבד.} two {# הודעות שלא נשלחו יאבדו.} other {# הודעות שלא נשלחו יאבדו.}}", + "clear_confirm": "ניקוי", + "persist_refused": "הדפדפן הזה עלול למחוק את הדואר השמור כשנגמר המקום. התקנת האפליקציה בדרך כלל מונעת זאת.", + "persist_retry": "לשאול שוב", + "safari_hint": "Safari מוחק את נתוני האתר אחרי 7 ימים בלי ביקור. הוסיפו את Bulwark למסך הבית או ל-Dock כדי לשמור את הדואר במכשיר הזה.", + "evicted": "הדפדפן מחק את הדואר ששמור במכשיר הזה. הוא יורד מחדש.", + "evicted_lost": "{count, plural, one {הודעה אחת או שינוי אחד שלא נשלחו אבדו.} two {# הודעות או שינויים שלא נשלחו אבדו.} 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} במצב לא מקוון" + }, "deep_link": { "message_not_found": "הודעה זו אינה זמינה עוד.", "thread_not_found": "שיחה זו אינה זמינה עוד.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 07cca9946..cee547999 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5756,6 +5756,61 @@ "toast": "Offline végzett, nem alkalmazott módosítások: {count}. A postázandók mappa mutatja, melyek.", "storage_failed": "Ezen az eszközön nincs több hely az offline leveleknek. Szabadítson fel helyet, vagy tartson meg kevesebbet a beállításokban." }, + "offlineStorage": { + "keep_description": "Megtartja a leveleit, a naptárát, a névjegyeit és a kiválasztott fájlokat, titkosítva ebben a böngészőben. Offline olvashat, megjelölhet és áthelyezhet leveleket, a megírt levelek pedig a postázandók között várnak. Kijelentkezéskor minden törlődik.", + "window_label": "Mennyi időre visszamenőleg maradjon meg", + "window_description": "A régebbi levelek és naptáresemények lekerülnek erről az eszközről. Online állapotban továbbra is megnyithatja őket.", + "window_days": "{days, plural, one {# nap} other {# nap}}", + "window_year": "1 év", + "window_everything": "Minden", + "attachments_label": "Mellékletek megtartása", + "attachments_description": "A megtartott levelek mellékletei és az offline megtartott fájlok, összesen legfeljebb {total}. A legrégebbiek törlődnek először.", + "total_locked": "A szervezete legfeljebb {size} mellékletet és fájlt engedélyez.", + "attachment_cap_label": "A megtartott legnagyobb melléklet", + "usage_mail": "Levelek és keresési index", + "usage_attachments": "Mellékletek", + "usage_calendar": "Naptár", + "usage_contacts": "Névjegyek", + "usage_files": "Offline megtartott fájlok", + "usage_row": "{size} ({count, plural, one {# elem} other {# elem}})", + "progress_list": "Levelek listázása", + "progress_bodies": "Levelek letöltése", + "progress_threads": "Beszélgetések rendezése", + "progress_files": "Fájlok letöltése", + "progress_other": "Adatok áthozása erre az eszközre", + "progress_count": "{done} / {total}", + "progress_count_open": "eddig {done}", + "progress_left": "körülbelül {minutes, plural, one {# perc} other {# perc}} van hátra", + "clear_label": "Fiók kiürítése", + "clear_description": "Törli mindazt, amit ez az eszköz ehhez a fiókhoz megtart, majd újra letölti.", + "clear_button": "Kiürítés…", + "clear_confirm_title": "Kiüríti a fiókot ezen az eszközön?", + "clear_confirm_message": "Minden, amit ez az eszköz ehhez a fiókhoz megtart, törlődik és újra letöltődik. A szerveren lévő levelei megmaradnak.", + "clear_confirm_unsent": "{count, plural, one {# el nem küldött levél elvész.} other {# el nem küldött levél elvész.}}", + "clear_confirm": "Kiürítés", + "persist_refused": "Ez a böngésző törölheti a megtartott leveleket, ha fogy a hely. Az alkalmazás telepítése ezt általában megakadályozza.", + "persist_retry": "Újra rákérdez", + "safari_hint": "A Safari 7 nap látogatás nélkül törli egy webhely adatait. Adja hozzá a Bulwarkot a Főképernyőhöz vagy a Dockhoz, hogy a levelei az eszközön maradjanak.", + "evicted": "A böngésző törölte az ezen az eszközön megtartott leveleket. Újra letöltődnek.", + "evicted_lost": "{count, plural, one {# el nem küldött levél vagy módosítás elveszett.} other {# el nem küldött levél vagy módosítás elveszett.}}", + "evicted_dismiss": "OK", + "storage_full_title": "Az eszköz tárhelye megtelt.", + "storage_full_text": "A Bulwark nem ment több levelet ide. Tartson meg kevesebb levelet, vagy ürítse ki ezt a fiókot, hogy legyen hely.", + "open_settings": "Beállítások megnyitása", + "default_on": "A Bulwark ezen az eszközön tartja meg a leveleit, hogy kapcsolat nélkül is működjön. Ezt a Beállításokban módosíthatja.", + "conflict_calendar": "Naptáresemény", + "conflict_contacts": "Névjegy", + "kept_files_title": "Offline megtartott fájlok", + "kept_state_kept": "Ezen az eszközön", + "kept_state_waiting": "Letöltésre vár", + "kept_error_tooLarge": "Túl nagy a megtartáshoz", + "kept_error_overLimit": "Meghaladja az eszköz korlátját", + "kept_error_notAllowed": "A szervezete nem engedi a fájlok megtartását", + "kept_error_download": "Nem sikerült letölteni", + "keep_offline": "Megtartás offline", + "unpin": "Megtartás offline megszüntetése", + "unpin_label": "{name} offline megtartásának megszüntetése" + }, "deep_link": { "message_not_found": "Ez az üzenet már nem érhető el.", "thread_not_found": "Ez a beszélgetés már nem érhető el.", diff --git a/locales/it/common.json b/locales/it/common.json index 398c64b80..a5110bfd8 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5756,6 +5756,61 @@ "toast": "Modifiche fatte offline non applicate: {count}. La posta in uscita indica quali.", "storage_failed": "Questo dispositivo non ha più spazio per la posta offline. Libera spazio o conservane meno nelle impostazioni." }, + "offlineStorage": { + "keep_description": "Conserva la tua posta, il calendario, i contatti e i file che scegli, cifrati in questo browser. Offline puoi leggere, contrassegnare e spostare la posta, e ciò che scrivi aspetta nella posta in uscita. Uscendo dall'account viene rimosso tutto.", + "window_label": "Da quanto tempo conservare", + "window_description": "La posta e gli eventi più vecchi lasciano questo dispositivo. Puoi comunque aprirli quando sei online.", + "window_days": "{days, plural, one {# giorno} other {# giorni}}", + "window_year": "1 anno", + "window_everything": "Tutto", + "attachments_label": "Conserva gli allegati", + "attachments_description": "Allegati dei messaggi conservati e file che tieni offline, fino a {total} in totale. I più vecchi vengono rimossi per primi.", + "total_locked": "La tua organizzazione consente al massimo {size} di allegati e file.", + "attachment_cap_label": "Allegato più grande da conservare", + "usage_mail": "Messaggi e indice di ricerca", + "usage_attachments": "Allegati", + "usage_calendar": "Calendario", + "usage_contacts": "Contatti", + "usage_files": "File conservati offline", + "usage_row": "{size} ({count, plural, one {# elemento} other {# elementi}})", + "progress_list": "Elenco dei messaggi", + "progress_bodies": "Download dei messaggi", + "progress_threads": "Ordinamento delle conversazioni", + "progress_files": "Download dei file", + "progress_other": "Trasferimento dei dati su questo dispositivo", + "progress_count": "{done} di {total}", + "progress_count_open": "{done} finora", + "progress_left": "mancano circa {minutes, plural, one {# minuto} other {# minuti}}", + "clear_label": "Svuota questo account", + "clear_description": "Elimina tutto ciò che questo dispositivo conserva per questo account, poi lo scarica di nuovo.", + "clear_button": "Svuota…", + "clear_confirm_title": "Svuotare questo account su questo dispositivo?", + "clear_confirm_message": "Tutto ciò che questo dispositivo conserva per questo account viene eliminato e scaricato di nuovo. La tua posta sul server resta dov'è.", + "clear_confirm_unsent": "{count, plural, one {# messaggio non inviato andrà perso.} other {# messaggi non inviati andranno persi.}}", + "clear_confirm": "Svuota", + "persist_refused": "Questo browser può eliminare la posta conservata quando lo spazio scarseggia. Installare l'app di solito lo evita.", + "persist_retry": "Chiedi di nuovo", + "safari_hint": "Safari elimina i dati di un sito dopo 7 giorni senza visite. Aggiungi Bulwark alla schermata Home o al Dock per conservare la tua posta su questo dispositivo.", + "evicted": "Il browser ha eliminato la posta conservata su questo dispositivo. Viene scaricata di nuovo.", + "evicted_lost": "{count, plural, one {# messaggio o modifica non inviati sono andati persi.} other {# messaggi o modifiche non inviati sono andati persi.}}", + "evicted_dismiss": "OK", + "storage_full_title": "Lo spazio del dispositivo è esaurito.", + "storage_full_text": "Bulwark ha smesso di salvare la posta qui. Conserva meno posta o svuota questo account per fare spazio.", + "open_settings": "Apri impostazioni", + "default_on": "Bulwark conserva la tua posta su questo dispositivo, così funziona anche senza connessione. Puoi cambiarlo nelle impostazioni.", + "conflict_calendar": "Evento del calendario", + "conflict_contacts": "Contatto", + "kept_files_title": "File conservati offline", + "kept_state_kept": "Su questo dispositivo", + "kept_state_waiting": "In attesa del download", + "kept_error_tooLarge": "Troppo grande per essere conservato", + "kept_error_overLimit": "Oltre il limite di questo dispositivo", + "kept_error_notAllowed": "La tua organizzazione non consente di conservare i file", + "kept_error_download": "Impossibile scaricare", + "keep_offline": "Conserva offline", + "unpin": "Non conservare più offline", + "unpin_label": "Non conservare più {name} offline" + }, "deep_link": { "message_not_found": "Questo messaggio non è più disponibile.", "thread_not_found": "Questa conversazione non è più disponibile.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7f5688704..87afe9a32 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5756,6 +5756,61 @@ "toast": "オフライン中の変更で適用されなかったもの: {count} 件。送信トレイで確認できます。", "storage_failed": "このデバイスにはオフラインメール用の空き容量がありません。空き容量を増やすか、設定で保存する量を減らしてください。" }, + "offlineStorage": { + "keep_description": "メール、カレンダー、連絡先、選んだファイルを暗号化してこのブラウザーに保存します。オフラインでもメールを読む、フラグを付ける、移動することができ、書いたメールは送信トレイで待機します。サインアウトするとすべて削除されます。", + "window_label": "保存する期間", + "window_description": "これより古いメールとカレンダーの予定はこのデバイスから削除されます。オンラインのときは引き続き開けます。", + "window_days": "{days, plural, other {#日}}", + "window_year": "1年", + "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, other {#件}})", + "progress_list": "メッセージを一覧表示しています", + "progress_bodies": "メッセージをダウンロードしています", + "progress_threads": "スレッドを並べ替えています", + "progress_files": "ファイルをダウンロードしています", + "progress_other": "データをこのデバイスに取り込んでいます", + "progress_count": "{done} / {total}", + "progress_count_open": "ここまで {done}", + "progress_left": "残り約{minutes, plural, other {#分}}", + "clear_label": "このアカウントを消去", + "clear_description": "このデバイスに保存されているこのアカウントのデータをすべて削除し、あらためてダウンロードします。", + "clear_button": "消去…", + "clear_confirm_title": "このデバイスでこのアカウントを消去しますか?", + "clear_confirm_message": "このデバイスに保存されているこのアカウントのデータをすべて削除し、あらためてダウンロードします。サーバー上のメールはそのまま残ります。", + "clear_confirm_unsent": "{count, plural, other {未送信のメッセージ#件が失われます。}}", + "clear_confirm": "消去", + "persist_refused": "空き容量が少なくなると、このブラウザーは保存したメールを削除することがあります。アプリをインストールすると、たいていは防げます。", + "persist_retry": "もう一度確認", + "safari_hint": "Safari は、7日間アクセスがないサイトのデータを削除します。このデバイスにメールを残すには、Bulwark をホーム画面または Dock に追加してください。", + "evicted": "ブラウザーがこのデバイスに保存していたメールを削除しました。あらためてダウンロードしています。", + "evicted_lost": "{count, plural, other {未送信のメッセージまたは変更#件が失われました。}}", + "evicted_dismiss": "OK", + "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} のオフライン保存をやめる" + }, "deep_link": { "message_not_found": "このメッセージは利用できなくなりました。", "thread_not_found": "この会話は利用できなくなりました。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 4c06d6b3f..8a406007c 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5756,6 +5756,61 @@ "toast": "오프라인에서 변경했지만 적용되지 않은 항목: {count}개. 보낼 편지함에서 확인할 수 있습니다.", "storage_failed": "이 기기에 오프라인 메일을 저장할 공간이 없습니다. 공간을 확보하거나 설정에서 보관량을 줄이세요." }, + "offlineStorage": { + "keep_description": "메일, 캘린더, 연락처, 선택한 파일을 이 브라우저에 암호화해 보관합니다. 오프라인에서도 메일을 읽고, 플래그를 지정하고, 이동할 수 있으며, 작성한 메일은 보낼편지함에서 대기합니다. 로그아웃하면 모두 삭제됩니다.", + "window_label": "보관할 기간", + "window_description": "이보다 오래된 메일과 캘린더 일정은 이 기기에서 삭제됩니다. 온라인일 때는 계속 열어볼 수 있습니다.", + "window_days": "{days, plural, other {#일}}", + "window_year": "1년", + "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, other {#개}})", + "progress_list": "메시지 목록을 만드는 중", + "progress_bodies": "메시지를 다운로드하는 중", + "progress_threads": "대화를 정렬하는 중", + "progress_files": "파일을 다운로드하는 중", + "progress_other": "데이터를 이 기기로 가져오는 중", + "progress_count": "{total} 중 {done}", + "progress_count_open": "지금까지 {done}", + "progress_left": "약 {minutes, plural, other {#분}} 남음", + "clear_label": "이 계정 비우기", + "clear_description": "이 기기에 보관된 이 계정의 데이터를 모두 삭제한 뒤 다시 다운로드합니다.", + "clear_button": "비우기…", + "clear_confirm_title": "이 기기에서 이 계정을 비울까요?", + "clear_confirm_message": "이 기기에 보관된 이 계정의 데이터가 모두 삭제되고 다시 다운로드됩니다. 서버에 있는 메일은 그대로 남습니다.", + "clear_confirm_unsent": "{count, plural, other {보내지 않은 메시지 #개가 사라집니다.}}", + "clear_confirm": "비우기", + "persist_refused": "저장 공간이 부족해지면 이 브라우저가 보관한 메일을 삭제할 수 있습니다. 앱을 설치하면 대개 막을 수 있습니다.", + "persist_retry": "다시 요청", + "safari_hint": "Safari는 7일 동안 방문하지 않은 사이트의 데이터를 삭제합니다. 이 기기에 메일을 계속 보관하려면 Bulwark를 홈 화면이나 Dock에 추가하세요.", + "evicted": "브라우저가 이 기기에 보관한 메일을 삭제했습니다. 다시 다운로드하는 중입니다.", + "evicted_lost": "{count, plural, 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} 오프라인 보관 중지" + }, "deep_link": { "message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.", "thread_not_found": "이 대화를 더 이상 사용할 수 없습니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index 8ebac9b3b..454eaa1bd 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5756,6 +5756,61 @@ "toast": "Bezsaistē veiktās nepiemērotās izmaiņas: {count}. Izsūtne parāda, kuras.", "storage_failed": "Šajā ierīcē vairs nav vietas bezsaistes pastam. Atbrīvojiet vietu vai iestatījumos glabājiet mazāk." }, + "offlineStorage": { + "keep_description": "Glabā jūsu e-pastu, kalendāru, kontaktpersonas un izvēlētos failus šifrētus šajā pārlūkā. Bezsaistē varat lasīt, atzīmēt un pārvietot vēstules, un tas, ko rakstāt, gaida izsūtnē. Izrakstoties viss tiek noņemts.", + "window_label": "Cik tālu atpakaļ glabāt", + "window_description": "Vecākās vēstules un kalendāra notikumi pazūd no šīs ierīces. Tās joprojām varat atvērt, kad ir savienojums.", + "window_days": "{days, plural, zero {# dienu} one {# diena} other {# dienas}}", + "window_year": "1 gads", + "window_everything": "Viss", + "attachments_label": "Glabāt pielikumus", + "attachments_description": "Glabāto vēstuļu pielikumi un faili, ko glabājat bezsaistē, kopā līdz {total}. Vecākie tiek noņemti pirmie.", + "total_locked": "Jūsu organizācija atļauj ne vairāk kā {size} pielikumu un failu.", + "attachment_cap_label": "Lielākais glabājamais pielikums", + "usage_mail": "Vēstules un meklēšanas indekss", + "usage_attachments": "Pielikumi", + "usage_calendar": "Kalendārs", + "usage_contacts": "Kontaktpersonas", + "usage_files": "Bezsaistē glabātie faili", + "usage_row": "{size} ({count, plural, zero {# vienumu} one {# vienums} other {# vienumi}})", + "progress_list": "Vēstuļu saraksta veidošana", + "progress_bodies": "Vēstuļu lejupielāde", + "progress_threads": "Sarunu kārtošana", + "progress_files": "Failu lejupielāde", + "progress_other": "Datu pārnešana uz šo ierīci", + "progress_count": "{done} no {total}", + "progress_count_open": "līdz šim {done}", + "progress_left": "atlikušas apmēram {minutes, plural, zero {# minūšu} one {# minūte} other {# minūtes}}", + "clear_label": "Notīrīt šo kontu", + "clear_description": "Izdzēš visu, ko šī ierīce glabā šim kontam, un lejupielādē to no jauna.", + "clear_button": "Notīrīt…", + "clear_confirm_title": "Vai notīrīt šo kontu šajā ierīcē?", + "clear_confirm_message": "Viss, ko šī ierīce glabā šim kontam, tiek izdzēsts un lejupielādēts no jauna. Jūsu e-pasts serverī paliek.", + "clear_confirm_unsent": "{count, plural, zero {# neizsūtītu vēstuļu tiks zaudēts.} one {Tiks zaudēta # neizsūtīta vēstule.} other {Tiks zaudētas # neizsūtītas vēstules.}}", + "clear_confirm": "Notīrīt", + "persist_refused": "Šis pārlūks var izdzēst glabāto e-pastu, kad pietrūkst vietas. Lietotnes instalēšana to parasti novērš.", + "persist_retry": "Pajautāt vēlreiz", + "safari_hint": "Safari izdzēš vietnes datus pēc 7 dienām bez apmeklējuma. Pievienojiet Bulwark sākuma ekrānam vai Dock, lai e-pasts paliktu šajā ierīcē.", + "evicted": "Pārlūks izdzēsa šajā ierīcē glabāto e-pastu. Tas tiek lejupielādēts no jauna.", + "evicted_lost": "{count, plural, zero {Tika zaudētas # neizsūtītu vēstuļu vai izmaiņu.} one {Tika zaudēta # neizsūtīta vēstule vai izmaiņa.} other {Tika zaudētas # neizsūtītas vēstules vai izmaiņas.}}", + "evicted_dismiss": "Labi", + "storage_full_title": "Ierīces krātuve ir pilna.", + "storage_full_text": "Bulwark vairs šeit nesaglabā e-pastu. Glabājiet mazāk e-pasta vai notīriet šo kontu, lai atbrīvotu vietu.", + "open_settings": "Atvērt iestatījumus", + "default_on": "Bulwark glabā jūsu e-pastu šajā ierīcē, lai tas darbotos bez savienojuma. To varat mainīt iestatījumos.", + "conflict_calendar": "Kalendāra notikums", + "conflict_contacts": "Kontaktpersona", + "kept_files_title": "Bezsaistē glabātie faili", + "kept_state_kept": "Šajā ierīcē", + "kept_state_waiting": "Gaida lejupielādi", + "kept_error_tooLarge": "Pārāk liels, lai glabātu", + "kept_error_overLimit": "Pārsniedz šīs ierīces ierobežojumu", + "kept_error_notAllowed": "Jūsu organizācija neatļauj glabāt failus", + "kept_error_download": "Nevarēja lejupielādēt", + "keep_offline": "Glabāt bezsaistē", + "unpin": "Pārtraukt glabāt bezsaistē", + "unpin_label": "Pārtraukt glabāt {name} bezsaistē" + }, "deep_link": { "message_not_found": "Šī ziņa vairs nav pieejama.", "thread_not_found": "Šī sarakste vairs nav pieejama.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 43edeb367..d7bfaae1f 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5756,6 +5756,61 @@ "toast": "Офлайн хийсэн, хэрэгжээгүй өөрчлөлтүүд: {count}. Аль нь болохыг Илгээх хайрцгаас харна уу.", "storage_failed": "Энэ төхөөрөмжид офлайн захидалд зай үлдсэнгүй. Зай чөлөөлөх эсвэл тохиргооноос цөөнийг хадгална уу." }, + "offlineStorage": { + "keep_description": "Таны имэйл, хуанли, харилцагчид болон сонгосон файлуудыг энэ хөтөч дээр шифрлэн хадгална. Офлайн үед имэйл уншиж, тэмдэглэж, зөөж болох ба бичсэн зүйл тань гарах хайрцагт хүлээнэ. Гарахад бүгд устна.", + "window_label": "Хэр удаан хугацаагаар хадгалах вэ", + "window_description": "Илүү хуучин имэйл болон хуанлийн үйл явдлууд энэ төхөөрөмжөөс устна. Холболттой үед та тэдгээрийг нээж болно.", + "window_days": "{days, plural, one {# өдөр} other {# өдөр}}", + "window_year": "1 жил", + "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, one {# зүйл} other {# зүйл}})", + "progress_list": "Мессежүүдийг жагсааж байна", + "progress_bodies": "Мессежүүдийг татаж байна", + "progress_threads": "Харилцан яриаг эрэмбэлж байна", + "progress_files": "Файлуудыг татаж байна", + "progress_other": "Өгөгдлийг энэ төхөөрөмж рүү авчирч байна", + "progress_count": "{total}-аас {done}", + "progress_count_open": "одоогоор {done}", + "progress_left": "ойролцоогоор {minutes, plural, one {# минут} other {# минут}} үлдсэн", + "clear_label": "Энэ бүртгэлийг цэвэрлэх", + "clear_description": "Энэ төхөөрөмж уг бүртгэлд зориулж хадгалсан бүхнийг устгаад дахин татна.", + "clear_button": "Цэвэрлэх…", + "clear_confirm_title": "Энэ бүртгэлийг энэ төхөөрөмж дээр цэвэрлэх үү?", + "clear_confirm_message": "Энэ төхөөрөмж уг бүртгэлд зориулж хадгалсан бүхэн устаж, дахин татагдана. Серверийн имэйл тань хэвээр үлдэнэ.", + "clear_confirm_unsent": "{count, plural, one {# илгээгээгүй мессеж алдагдана.} other {# илгээгээгүй мессеж алдагдана.}}", + "clear_confirm": "Цэвэрлэх", + "persist_refused": "Зай дутвал энэ хөтөч хадгалсан имэйлийг устгаж болно. Аппыг суулгавал ихэвчлэн ийм зүйл болдоггүй.", + "persist_retry": "Дахин асуух", + "safari_hint": "Safari сайтад 7 хоног зочлоогүй бол түүний өгөгдлийг устгадаг. Имэйлээ энэ төхөөрөмж дээр хадгалахын тулд Bulwark-ыг Үндсэн дэлгэц эсвэл Dock-д нэмнэ үү.", + "evicted": "Хөтөч энэ төхөөрөмж дээр хадгалсан имэйлийг устгасан. Дахин татаж байна.", + "evicted_lost": "{count, plural, one {# илгээгээгүй мессеж эсвэл өөрчлөлт алдагдсан.} other {# илгээгээгүй мессеж эсвэл өөрчлөлт алдагдсан.}}", + "evicted_dismiss": "OK", + "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}-г офлайн хадгалахаа болих" + }, "deep_link": { "message_not_found": "Энэ зурвас дахин ашиглах боломжгүй болсон.", "thread_not_found": "Энэ харилцан яриа дахин ашиглах боломжгүй болсон.", diff --git a/locales/nb/common.json b/locales/nb/common.json index 267f73a56..1f73582b6 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5756,6 +5756,61 @@ "toast": "Endringer gjort uten nett som ikke ble utført: {count}. Utboksen viser hvilke.", "storage_failed": "Denne enheten har ikke mer plass til e-post uten nett. Frigjør plass, eller behold mindre i innstillingene." }, + "offlineStorage": { + "keep_description": "Beholder e-posten, kalenderen, kontaktene og filene du velger, kryptert i denne nettleseren. Frakoblet kan du lese, flagge og flytte e-post, og det du skriver, venter i utboksen. Når du logger av, fjernes alt.", + "window_label": "Hvor langt tilbake som beholdes", + "window_description": "Eldre e-post og kalenderhendelser forlater denne enheten. Du kan fortsatt åpne dem når du er tilkoblet.", + "window_days": "{days, plural, one {# dag} other {# dager}}", + "window_year": "1 år", + "window_everything": "Alt", + "attachments_label": "Behold vedlegg", + "attachments_description": "Vedlegg fra beholdte meldinger og filene du beholder frakoblet, opptil {total} til sammen. De eldste fjernes først.", + "total_locked": "Organisasjonen din tillater høyst {size} med vedlegg og filer.", + "attachment_cap_label": "Største vedlegg som beholdes", + "usage_mail": "Meldinger og søkeindeks", + "usage_attachments": "Vedlegg", + "usage_calendar": "Kalender", + "usage_contacts": "Kontakter", + "usage_files": "Filer beholdt frakoblet", + "usage_row": "{size} ({count, plural, one {# element} other {# elementer}})", + "progress_list": "Lister opp meldinger", + "progress_bodies": "Laster ned meldinger", + "progress_threads": "Sorterer samtaler", + "progress_files": "Laster ned filer", + "progress_other": "Henter data til denne enheten", + "progress_count": "{done} av {total}", + "progress_count_open": "{done} så langt", + "progress_left": "omtrent {minutes, plural, one {# minutt} other {# minutter}} igjen", + "clear_label": "Tøm denne kontoen", + "clear_description": "Sletter alt denne enheten beholder for denne kontoen, og laster det ned på nytt.", + "clear_button": "Tøm…", + "clear_confirm_title": "Tømme denne kontoen på denne enheten?", + "clear_confirm_message": "Alt denne enheten beholder for denne kontoen, slettes og lastes ned på nytt. E-posten din på serveren blir værende.", + "clear_confirm_unsent": "{count, plural, one {# usendt melding går tapt.} other {# usendte meldinger går tapt.}}", + "clear_confirm": "Tøm", + "persist_refused": "Denne nettleseren kan slette den beholdte e-posten når plassen blir knapp. Å installere appen hjelper vanligvis.", + "persist_retry": "Spør igjen", + "safari_hint": "Safari sletter data fra et nettsted etter 7 dager uten besøk. Legg Bulwark til på Hjem-skjermen eller i Dock for å beholde e-posten på denne enheten.", + "evicted": "Nettleseren slettet e-posten som var beholdt på denne enheten. Den lastes ned på nytt.", + "evicted_lost": "{count, plural, one {# usendt melding eller endring gikk tapt.} other {# usendte meldinger eller endringer gikk tapt.}}", + "evicted_dismiss": "OK", + "storage_full_title": "Lagringsplassen på enheten er full.", + "storage_full_text": "Bulwark har sluttet å lagre e-post her. Behold mindre e-post, eller tøm denne kontoen for å få plass.", + "open_settings": "Åpne innstillinger", + "default_on": "Bulwark beholder e-posten din på denne enheten, så den fungerer uten tilkobling. Du kan endre dette i Innstillinger.", + "conflict_calendar": "Kalenderhendelse", + "conflict_contacts": "Kontakt", + "kept_files_title": "Filer beholdt frakoblet", + "kept_state_kept": "På denne enheten", + "kept_state_waiting": "Venter på nedlasting", + "kept_error_tooLarge": "For stor til å beholdes", + "kept_error_overLimit": "Over grensen for denne enheten", + "kept_error_notAllowed": "Organisasjonen din tillater ikke at filer beholdes", + "kept_error_download": "Kunne ikke lastes ned", + "keep_offline": "Behold frakoblet", + "unpin": "Slutt å beholde frakoblet", + "unpin_label": "Slutt å beholde {name} frakoblet" + }, "deep_link": { "message_not_found": "Denne meldingen er ikke lenger tilgjengelig.", "thread_not_found": "Denne samtalen er ikke lenger tilgjengelig.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 1cf1cabbb..b454337fd 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5756,6 +5756,61 @@ "toast": "Offline gemaakte wijzigingen niet doorgevoerd: {count}. Het Postvak UIT laat zien welke.", "storage_failed": "Dit apparaat heeft geen ruimte meer voor offline mail. Maak ruimte vrij of bewaar minder via Instellingen." }, + "offlineStorage": { + "keep_description": "Bewaart je e-mail, agenda, contacten en gekozen bestanden versleuteld in deze browser. Offline kun je e-mail lezen, markeren en verplaatsen, en wat je schrijft wacht in Postvak UIT. Afmelden verwijdert alles.", + "window_label": "Hoe ver terug bewaren", + "window_description": "Oudere e-mail en agenda-items verdwijnen van dit apparaat. Je kunt ze nog openen als je online bent.", + "window_days": "{days, plural, one {# dag} other {# dagen}}", + "window_year": "1 jaar", + "window_everything": "Alles", + "attachments_label": "Bijlagen bewaren", + "attachments_description": "Bijlagen van bewaarde berichten en de bestanden die je offline bewaart, samen maximaal {total}. De oudste gaan eerst weg.", + "total_locked": "Je organisatie staat maximaal {size} aan bijlagen en bestanden toe.", + "attachment_cap_label": "Grootste bijlage om te bewaren", + "usage_mail": "Berichten en zoekindex", + "usage_attachments": "Bijlagen", + "usage_calendar": "Agenda", + "usage_contacts": "Contacten", + "usage_files": "Offline bewaarde bestanden", + "usage_row": "{size} ({count, plural, one {# item} other {# items}})", + "progress_list": "Berichten worden opgesomd", + "progress_bodies": "Berichten worden gedownload", + "progress_threads": "Gesprekken worden gesorteerd", + "progress_files": "Bestanden worden gedownload", + "progress_other": "Gegevens worden naar dit apparaat gehaald", + "progress_count": "{done} van {total}", + "progress_count_open": "{done} tot nu toe", + "progress_left": "nog ongeveer {minutes, plural, one {# minuut} other {# minuten}}", + "clear_label": "Dit account wissen", + "clear_description": "Verwijdert alles wat dit apparaat voor dit account bewaart en downloadt het daarna opnieuw.", + "clear_button": "Wissen…", + "clear_confirm_title": "Dit account op dit apparaat wissen?", + "clear_confirm_message": "Alles wat dit apparaat voor dit account bewaart, wordt verwijderd en opnieuw gedownload. Je e-mail op de server blijft staan.", + "clear_confirm_unsent": "{count, plural, one {# niet-verzonden bericht gaat verloren.} other {# niet-verzonden berichten gaan verloren.}}", + "clear_confirm": "Wissen", + "persist_refused": "Deze browser kan de bewaarde e-mail verwijderen als de ruimte opraakt. De app installeren voorkomt dat meestal.", + "persist_retry": "Opnieuw vragen", + "safari_hint": "Safari verwijdert de gegevens van een site na 7 dagen zonder bezoek. Zet Bulwark op je beginscherm of in het Dock om je e-mail op dit apparaat te houden.", + "evicted": "De browser heeft de e-mail verwijderd die op dit apparaat was bewaard. Ze wordt opnieuw gedownload.", + "evicted_lost": "{count, plural, one {# niet-verzonden bericht of wijziging is verloren gegaan.} other {# niet-verzonden berichten of wijzigingen zijn verloren gegaan.}}", + "evicted_dismiss": "OK", + "storage_full_title": "De opslag van het apparaat is vol.", + "storage_full_text": "Bulwark slaat hier geen e-mail meer op. Bewaar minder e-mail of wis dit account om ruimte te maken.", + "open_settings": "Instellingen openen", + "default_on": "Bulwark bewaart je e-mail op dit apparaat, zodat het ook zonder verbinding werkt. Je kunt dit wijzigen in de instellingen.", + "conflict_calendar": "Agenda-item", + "conflict_contacts": "Contact", + "kept_files_title": "Offline bewaarde bestanden", + "kept_state_kept": "Op dit apparaat", + "kept_state_waiting": "Wacht op download", + "kept_error_tooLarge": "Te groot om te bewaren", + "kept_error_overLimit": "Boven de limiet voor dit apparaat", + "kept_error_notAllowed": "Je organisatie staat het bewaren van bestanden niet toe", + "kept_error_download": "Kon niet worden gedownload", + "keep_offline": "Offline bewaren", + "unpin": "Niet meer offline bewaren", + "unpin_label": "{name} niet meer offline bewaren" + }, "deep_link": { "message_not_found": "Dit bericht is niet meer beschikbaar.", "thread_not_found": "Dit gesprek is niet meer beschikbaar.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 7067bbaaa..34a22cd03 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5756,6 +5756,61 @@ "toast": "Niezastosowane zmiany wprowadzone offline: {count}. Skrzynka nadawcza pokazuje które.", "storage_failed": "Na tym urządzeniu nie ma już miejsca na pocztę offline. Zwolnij miejsce lub zachowuj mniej w ustawieniach." }, + "offlineStorage": { + "keep_description": "Przechowuje Twoją pocztę, kalendarz, kontakty i wybrane pliki zaszyfrowane w tej przeglądarce. Offline możesz czytać, oznaczać i przenosić wiadomości, a to, co napiszesz, czeka w skrzynce nadawczej. Wylogowanie usuwa wszystko.", + "window_label": "Jak daleko wstecz przechowywać", + "window_description": "Starsza poczta i wydarzenia z kalendarza znikają z tego urządzenia. Nadal możesz je otworzyć, gdy masz połączenie.", + "window_days": "{days, plural, one {# dzień} few {# dni} many {# dni} other {# dnia}}", + "window_year": "1 rok", + "window_everything": "Wszystko", + "attachments_label": "Przechowuj załączniki", + "attachments_description": "Załączniki przechowywanych wiadomości i pliki przechowywane offline, razem do {total}. Najstarsze są usuwane najpierw.", + "total_locked": "Twoja organizacja zezwala na najwyżej {size} załączników i plików.", + "attachment_cap_label": "Największy przechowywany załącznik", + "usage_mail": "Wiadomości i indeks wyszukiwania", + "usage_attachments": "Załączniki", + "usage_calendar": "Kalendarz", + "usage_contacts": "Kontakty", + "usage_files": "Pliki przechowywane offline", + "usage_row": "{size} ({count, plural, one {# element} few {# elementy} many {# elementów} other {# elementu}})", + "progress_list": "Wyszukiwanie wiadomości", + "progress_bodies": "Pobieranie wiadomości", + "progress_threads": "Sortowanie konwersacji", + "progress_files": "Pobieranie plików", + "progress_other": "Przenoszenie danych na to urządzenie", + "progress_count": "{done} z {total}", + "progress_count_open": "dotąd {done}", + "progress_left": "zostało około {minutes, plural, one {# minuty} few {# minut} many {# minut} other {# minuty}}", + "clear_label": "Wyczyść to konto", + "clear_description": "Usuwa wszystko, co to urządzenie przechowuje dla tego konta, a potem pobiera to ponownie.", + "clear_button": "Wyczyść…", + "clear_confirm_title": "Wyczyścić to konto na tym urządzeniu?", + "clear_confirm_message": "Wszystko, co to urządzenie przechowuje dla tego konta, zostanie usunięte i pobrane ponownie. Twoja poczta na serwerze zostaje.", + "clear_confirm_unsent": "{count, plural, one {# niewysłana wiadomość zostanie utracona.} few {# niewysłane wiadomości zostaną utracone.} many {# niewysłanych wiadomości zostanie utraconych.} other {# niewysłanej wiadomości zostanie utraconej.}}", + "clear_confirm": "Wyczyść", + "persist_refused": "Ta przeglądarka może usunąć przechowywaną pocztę, gdy zabraknie miejsca. Zainstalowanie aplikacji zwykle temu zapobiega.", + "persist_retry": "Zapytaj ponownie", + "safari_hint": "Safari usuwa dane witryny po 7 dniach bez odwiedzin. Dodaj Bulwark do ekranu początkowego lub do Docka, aby zachować pocztę na tym urządzeniu.", + "evicted": "Przeglądarka usunęła pocztę przechowywaną na tym urządzeniu. Jest pobierana ponownie.", + "evicted_lost": "{count, plural, one {Utracono # niewysłaną wiadomość lub zmianę.} few {Utracono # niewysłane wiadomości lub zmiany.} many {Utracono # niewysłanych wiadomości lub zmian.} other {Utracono # niewysłanej wiadomości lub zmiany.}}", + "evicted_dismiss": "OK", + "storage_full_title": "Pamięć urządzenia jest pełna.", + "storage_full_text": "Bulwark przestał tu zapisywać pocztę. Przechowuj mniej poczty albo wyczyść to konto, aby zwolnić miejsce.", + "open_settings": "Otwórz ustawienia", + "default_on": "Bulwark przechowuje Twoją pocztę na tym urządzeniu, żeby działała bez połączenia. Możesz to zmienić w Ustawieniach.", + "conflict_calendar": "Wydarzenie w kalendarzu", + "conflict_contacts": "Kontakt", + "kept_files_title": "Pliki przechowywane offline", + "kept_state_kept": "Na tym urządzeniu", + "kept_state_waiting": "Czeka na pobranie", + "kept_error_tooLarge": "Za duży, aby go przechowywać", + "kept_error_overLimit": "Przekracza limit tego urządzenia", + "kept_error_notAllowed": "Twoja organizacja nie zezwala na przechowywanie plików", + "kept_error_download": "Nie udało się pobrać", + "keep_offline": "Przechowuj offline", + "unpin": "Nie przechowuj już offline", + "unpin_label": "Nie przechowuj już {name} offline" + }, "deep_link": { "message_not_found": "Ta wiadomość nie jest już dostępna.", "thread_not_found": "Ta rozmowa nie jest już dostępna.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 4bce29dd5..0e5413f17 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5756,6 +5756,61 @@ "toast": "Alterações feitas offline não aplicadas: {count}. A caixa de saída mostra quais.", "storage_failed": "Este dispositivo não tem espaço para o correio offline. Liberte espaço ou guarde menos nas definições." }, + "offlineStorage": { + "keep_description": "Mantém o seu correio, calendário, contactos e os ficheiros que escolher, cifrados neste navegador. Offline pode ler, sinalizar e mover e-mails, e o que escrever espera na caixa de saída. Terminar a sessão remove tudo.", + "window_label": "Quanto tempo atrás manter", + "window_description": "Os e-mails e eventos mais antigos saem deste dispositivo. Pode continuar a abri-los quando tiver ligação.", + "window_days": "{days, plural, one {# dia} other {# dias}}", + "window_year": "1 ano", + "window_everything": "Tudo", + "attachments_label": "Manter anexos", + "attachments_description": "Anexos das mensagens mantidas e ficheiros que mantém offline, até {total} no total. Os mais antigos saem primeiro.", + "total_locked": "A sua organização permite no máximo {size} de anexos e ficheiros.", + "attachment_cap_label": "Maior anexo a manter", + "usage_mail": "Mensagens e índice de pesquisa", + "usage_attachments": "Anexos", + "usage_calendar": "Calendário", + "usage_contacts": "Contactos", + "usage_files": "Ficheiros mantidos offline", + "usage_row": "{size} ({count, plural, one {# item} other {# itens}})", + "progress_list": "A listar mensagens", + "progress_bodies": "A transferir mensagens", + "progress_threads": "A ordenar conversas", + "progress_files": "A transferir ficheiros", + "progress_other": "A trazer dados para este dispositivo", + "progress_count": "{done} de {total}", + "progress_count_open": "{done} até agora", + "progress_left": "faltam cerca de {minutes, plural, one {# minuto} other {# minutos}}", + "clear_label": "Limpar esta conta", + "clear_description": "Elimina tudo o que este dispositivo mantém para esta conta e volta a transferi-lo.", + "clear_button": "Limpar…", + "clear_confirm_title": "Limpar esta conta neste dispositivo?", + "clear_confirm_message": "Tudo o que este dispositivo mantém para esta conta é eliminado e transferido novamente. O seu correio no servidor mantém-se.", + "clear_confirm_unsent": "{count, plural, one {# mensagem por enviar será perdida.} other {# mensagens por enviar serão perdidas.}}", + "clear_confirm": "Limpar", + "persist_refused": "Este navegador pode eliminar o correio mantido quando o espaço escasseia. Instalar a aplicação costuma evitá-lo.", + "persist_retry": "Perguntar novamente", + "safari_hint": "O Safari elimina os dados de um site após 7 dias sem visitas. Adicione o Bulwark ao Ecrã Principal ou ao Dock para manter o seu correio neste dispositivo.", + "evicted": "O navegador eliminou o correio mantido neste dispositivo. Está a ser transferido novamente.", + "evicted_lost": "{count, plural, one {# mensagem ou alteração por enviar foi perdida.} other {# mensagens ou alterações por enviar foram perdidas.}}", + "evicted_dismiss": "OK", + "storage_full_title": "O armazenamento do dispositivo está cheio.", + "storage_full_text": "O Bulwark deixou de guardar correio aqui. Mantenha menos correio ou limpe esta conta para libertar espaço.", + "open_settings": "Abrir definições", + "default_on": "O Bulwark mantém o seu correio neste dispositivo, para funcionar sem ligação. Pode alterar isto nas Definições.", + "conflict_calendar": "Evento do calendário", + "conflict_contacts": "Contacto", + "kept_files_title": "Ficheiros mantidos offline", + "kept_state_kept": "Neste dispositivo", + "kept_state_waiting": "A aguardar transferência", + "kept_error_tooLarge": "Demasiado grande para manter", + "kept_error_overLimit": "Acima do limite deste dispositivo", + "kept_error_notAllowed": "A sua organização não permite manter ficheiros", + "kept_error_download": "Não foi possível transferir", + "keep_offline": "Manter offline", + "unpin": "Deixar de manter offline", + "unpin_label": "Deixar de manter {name} offline" + }, "deep_link": { "message_not_found": "Esta mensagem não está mais disponível.", "thread_not_found": "Esta conversa não está mais disponível.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 17abb3f3a..413843d3a 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5756,6 +5756,61 @@ "toast": "Modificări făcute offline care nu au fost aplicate: {count}. Căsuța de trimitere arată care.", "storage_failed": "Acest dispozitiv nu mai are loc pentru e-mailul offline. Eliberați spațiu sau păstrați mai puțin din setări." }, + "offlineStorage": { + "keep_description": "Păstrează e-mailurile, calendarul, contactele și fișierele alese de dvs., criptate în acest browser. Offline puteți citi, marca și muta e-mailuri, iar ce scrieți așteaptă în Mesaje de trimis. La deconectare se șterge totul.", + "window_label": "Cât de departe în urmă se păstrează", + "window_description": "E-mailurile și evenimentele mai vechi dispar de pe acest dispozitiv. Le puteți deschide în continuare când sunteți online.", + "window_days": "{days, plural, one {# zi} few {# zile} other {# de zile}}", + "window_year": "1 an", + "window_everything": "Tot", + "attachments_label": "Păstrează atașamentele", + "attachments_description": "Atașamentele mesajelor păstrate și fișierele păstrate offline, în total până la {total}. Cele mai vechi sunt șterse primele.", + "total_locked": "Organizația dvs. permite cel mult {size} de atașamente și fișiere.", + "attachment_cap_label": "Cel mai mare atașament păstrat", + "usage_mail": "Mesaje și index de căutare", + "usage_attachments": "Atașamente", + "usage_calendar": "Calendar", + "usage_contacts": "Contacte", + "usage_files": "Fișiere păstrate offline", + "usage_row": "{size} ({count, plural, one {# element} few {# elemente} other {# de elemente}})", + "progress_list": "Se listează mesajele", + "progress_bodies": "Se descarcă mesajele", + "progress_threads": "Se sortează conversațiile", + "progress_files": "Se descarcă fișierele", + "progress_other": "Se aduc date pe acest dispozitiv", + "progress_count": "{done} din {total}", + "progress_count_open": "{done} până acum", + "progress_left": "mai durează aproximativ {minutes, plural, one {# minut} few {# minute} other {# de minute}}", + "clear_label": "Golește acest cont", + "clear_description": "Șterge tot ce păstrează acest dispozitiv pentru acest cont, apoi descarcă din nou.", + "clear_button": "Golește…", + "clear_confirm_title": "Goliți acest cont pe acest dispozitiv?", + "clear_confirm_message": "Tot ce păstrează acest dispozitiv pentru acest cont este șters și descărcat din nou. E-mailurile dvs. de pe server rămân.", + "clear_confirm_unsent": "{count, plural, one {# mesaj netrimis se pierde.} few {# mesaje netrimise se pierd.} other {# de mesaje netrimise se pierd.}}", + "clear_confirm": "Golește", + "persist_refused": "Acest browser poate șterge e-mailurile păstrate când se termină spațiul. Instalarea aplicației de obicei previne asta.", + "persist_retry": "Întreabă din nou", + "safari_hint": "Safari șterge datele unui site după 7 zile fără vizite. Adăugați Bulwark pe ecranul principal sau în Dock ca să păstrați e-mailurile pe acest dispozitiv.", + "evicted": "Browserul a șters e-mailurile păstrate pe acest dispozitiv. Se descarcă din nou.", + "evicted_lost": "{count, plural, one {# mesaj sau modificare netrimis s-a pierdut.} few {# mesaje sau modificări netrimise s-au pierdut.} other {# de mesaje sau modificări netrimise s-au pierdut.}}", + "evicted_dismiss": "OK", + "storage_full_title": "Spațiul dispozitivului este plin.", + "storage_full_text": "Bulwark nu mai salvează e-mailuri aici. Păstrați mai puține e-mailuri sau goliți acest cont pentru a face loc.", + "open_settings": "Deschide setările", + "default_on": "Bulwark păstrează e-mailurile pe acest dispozitiv, ca să funcționeze fără conexiune. Puteți schimba asta în Setări.", + "conflict_calendar": "Eveniment din calendar", + "conflict_contacts": "Contact", + "kept_files_title": "Fișiere păstrate offline", + "kept_state_kept": "Pe acest dispozitiv", + "kept_state_waiting": "Așteaptă descărcarea", + "kept_error_tooLarge": "Prea mare pentru a fi păstrat", + "kept_error_overLimit": "Peste limita acestui dispozitiv", + "kept_error_notAllowed": "Organizația dvs. nu permite păstrarea fișierelor", + "kept_error_download": "Nu a putut fi descărcat", + "keep_offline": "Păstrează offline", + "unpin": "Nu mai păstra offline", + "unpin_label": "Nu mai păstra {name} offline" + }, "deep_link": { "message_not_found": "Acest mesaj nu mai este disponibil.", "thread_not_found": "Această conversație nu mai este disponibilă.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 9d50c9b45..dd3538569 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5756,6 +5756,61 @@ "toast": "Изменения, внесённые офлайн и не применённые: {count}. Какие именно, видно в исходящих.", "storage_failed": "На этом устройстве не осталось места для офлайн-почты. Освободите место или храните меньше в настройках." }, + "offlineStorage": { + "keep_description": "Хранит вашу почту, календарь, контакты и выбранные файлы в зашифрованном виде в этом браузере. Без сети можно читать, помечать и перемещать письма, а написанное ждёт в исходящих. При выходе всё удаляется.", + "window_label": "За какой период хранить", + "window_description": "Более старые письма и события календаря удаляются с этого устройства. Их по-прежнему можно открыть, когда есть сеть.", + "window_days": "{days, plural, one {# день} few {# дня} many {# дней} other {# дня}}", + "window_year": "1 год", + "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, one {# элемент} 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, one {# минута} few {# минуты} many {# минут} other {# минуты}}", + "clear_label": "Очистить эту учётную запись", + "clear_description": "Удаляет всё, что это устройство хранит для этой учётной записи, и загружает заново.", + "clear_button": "Очистить…", + "clear_confirm_title": "Очистить эту учётную запись на этом устройстве?", + "clear_confirm_message": "Всё, что это устройство хранит для этой учётной записи, будет удалено и загружено заново. Ваша почта на сервере останется.", + "clear_confirm_unsent": "{count, plural, one {# неотправленное письмо будет потеряно.} few {# неотправленных письма будут потеряны.} many {# неотправленных писем будут потеряны.} other {# неотправленного письма будет потеряно.}}", + "clear_confirm": "Очистить", + "persist_refused": "Этот браузер может удалить сохранённую почту, когда не хватает места. Установка приложения обычно это предотвращает.", + "persist_retry": "Спросить снова", + "safari_hint": "Safari удаляет данные сайта, если его не открывали 7 дней. Добавьте Bulwark на экран «Домой» или в Dock, чтобы почта оставалась на этом устройстве.", + "evicted": "Браузер удалил почту, сохранённую на этом устройстве. Она загружается заново.", + "evicted_lost": "{count, plural, one {Потеряно # неотправленное письмо или изменение.} few {Потеряно # неотправленных письма или изменения.} many {Потеряно # неотправленных писем или изменений.} other {Потеряно # неотправленного письма или изменения.}}", + "evicted_dismiss": "OK", + "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} без сети" + }, "deep_link": { "message_not_found": "Это письмо больше недоступно.", "thread_not_found": "Эта переписка больше недоступна.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 13b2e0ae2..706c90fe2 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5756,6 +5756,61 @@ "toast": "Nepoužité zmeny urobené offline: {count}. Ktoré to sú, ukazuje pošta na odoslanie.", "storage_failed": "V tomto zariadení už nie je miesto na poštu offline. Uvoľnite miesto alebo v nastaveniach ponechajte menej." }, + "offlineStorage": { + "keep_description": "Uchováva vašu poštu, kalendár, kontakty a vybrané súbory zašifrované v tomto prehliadači. Offline môžete poštu čítať, označovať a presúvať a to, čo napíšete, čaká na odoslanie. Odhlásenie všetko odstráni.", + "window_label": "Ako dlho do minulosti uchovávať", + "window_description": "Staršia pošta a udalosti z kalendára toto zariadenie opustia. Keď budete online, môžete ich stále otvoriť.", + "window_days": "{days, plural, one {# deň} few {# dni} many {# dňa} other {# dní}}", + "window_year": "1 rok", + "window_everything": "Všetko", + "attachments_label": "Uchovávať prílohy", + "attachments_description": "Prílohy uchovaných správ a súbory, ktoré uchovávate offline, spolu až {total}. Najstaršie sa odstránia ako prvé.", + "total_locked": "Vaša organizácia povoľuje najviac {size} príloh a súborov.", + "attachment_cap_label": "Najväčšia uchovávaná príloha", + "usage_mail": "Správy a index vyhľadávania", + "usage_attachments": "Prílohy", + "usage_calendar": "Kalendár", + "usage_contacts": "Kontakty", + "usage_files": "Súbory uchované offline", + "usage_row": "{size} ({count, plural, one {# položka} few {# položky} many {# položky} other {# položiek}})", + "progress_list": "Vypisujú sa správy", + "progress_bodies": "Sťahujú sa správy", + "progress_threads": "Radia sa konverzácie", + "progress_files": "Sťahujú sa súbory", + "progress_other": "Prenášajú sa dáta do tohto zariadenia", + "progress_count": "{done} z {total}", + "progress_count_open": "zatiaľ {done}", + "progress_left": "zostáva asi {minutes, plural, one {# minúta} few {# minúty} many {# minúty} other {# minút}}", + "clear_label": "Vymazať tento účet", + "clear_description": "Zmaže všetko, čo toto zariadenie pre tento účet uchováva, a stiahne to znova.", + "clear_button": "Vymazať…", + "clear_confirm_title": "Vymazať tento účet na tomto zariadení?", + "clear_confirm_message": "Všetko, čo toto zariadenie pre tento účet uchováva, sa zmaže a stiahne znova. Vaša pošta na serveri zostane.", + "clear_confirm_unsent": "{count, plural, one {# neodoslaná správa sa stratí.} few {# neodoslané správy sa stratia.} many {# neodoslanej správy sa stratí.} other {# neodoslaných správ sa stratí.}}", + "clear_confirm": "Vymazať", + "persist_refused": "Tento prehliadač môže uchovanú poštu zmazať, keď dôjde miesto. Inštalácia aplikácie tomu zvyčajne zabráni.", + "persist_retry": "Opýtať sa znova", + "safari_hint": "Safari zmaže dáta webu po 7 dňoch bez návštevy. Pridajte Bulwark na plochu alebo do Docku, aby vaša pošta na tomto zariadení zostala.", + "evicted": "Prehliadač zmazal poštu uchovanú na tomto zariadení. Sťahuje sa znova.", + "evicted_lost": "{count, plural, one {# neodoslaná správa alebo zmena sa stratila.} few {# neodoslané správy alebo zmeny sa stratili.} many {# neodoslanej správy alebo zmeny sa stratilo.} other {# neodoslaných správ alebo zmien sa stratilo.}}", + "evicted_dismiss": "OK", + "storage_full_title": "Úložisko zariadenia je plné.", + "storage_full_text": "Bulwark sem prestal ukladať poštu. Uchovávajte menej pošty alebo vymažte tento účet, aby sa uvoľnilo miesto.", + "open_settings": "Otvoriť nastavenia", + "default_on": "Bulwark uchováva vašu poštu na tomto zariadení, aby fungoval aj bez pripojenia. Môžete to zmeniť v Nastaveniach.", + "conflict_calendar": "Udalosť v kalendári", + "conflict_contacts": "Kontakt", + "kept_files_title": "Súbory uchované offline", + "kept_state_kept": "Na tomto zariadení", + "kept_state_waiting": "Čaká na stiahnutie", + "kept_error_tooLarge": "Príliš veľký na uchovanie", + "kept_error_overLimit": "Nad limit tohto zariadenia", + "kept_error_notAllowed": "Vaša organizácia uchovávanie súborov nepovoľuje", + "kept_error_download": "Nepodarilo sa stiahnuť", + "keep_offline": "Uchovať offline", + "unpin": "Prestať uchovávať offline", + "unpin_label": "Prestať uchovávať {name} offline" + }, "deep_link": { "message_not_found": "Táto správa už nie je dostupná.", "thread_not_found": "Táto konverzácia už nie je dostupná.", diff --git a/locales/th/common.json b/locales/th/common.json index a1cb466d9..ce9b58928 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2101,6 +2101,61 @@ "toast": "การเปลี่ยนแปลงขณะออฟไลน์ที่ไม่ได้นำไปใช้: {count} ดูรายการได้ในกล่องขาออก", "storage_failed": "อุปกรณ์นี้ไม่มีที่ว่างสำหรับอีเมลออฟไลน์แล้ว โปรดเพิ่มพื้นที่ว่างหรือเก็บให้น้อยลงในการตั้งค่า" }, + "offlineStorage": { + "keep_description": "เก็บอีเมล ปฏิทิน รายชื่อติดต่อ และไฟล์ที่คุณเลือกไว้ในเบราว์เซอร์นี้แบบเข้ารหัส เมื่อออฟไลน์คุณอ่าน ติดธง และย้ายอีเมลได้ และสิ่งที่คุณเขียนจะรออยู่ในกล่องขาออก เมื่อออกจากระบบทุกอย่างจะถูกลบ", + "window_label": "เก็บย้อนหลังนานแค่ไหน", + "window_description": "อีเมลและกิจกรรมในปฏิทินที่เก่ากว่านี้จะถูกนำออกจากอุปกรณ์นี้ คุณยังเปิดดูได้เมื่อออนไลน์", + "window_days": "{days, plural, other {# วัน}}", + "window_year": "1 ปี", + "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, other {# รายการ}})", + "progress_list": "กำลังแสดงรายการข้อความ", + "progress_bodies": "กำลังดาวน์โหลดข้อความ", + "progress_threads": "กำลังเรียงลำดับการสนทนา", + "progress_files": "กำลังดาวน์โหลดไฟล์", + "progress_other": "กำลังนำข้อมูลมาไว้ที่อุปกรณ์นี้", + "progress_count": "{done} จาก {total}", + "progress_count_open": "ตอนนี้ {done}", + "progress_left": "เหลือประมาณ {minutes, plural, other {# นาที}}", + "clear_label": "ล้างบัญชีนี้", + "clear_description": "ลบทุกอย่างที่อุปกรณ์นี้เก็บไว้สำหรับบัญชีนี้ แล้วดาวน์โหลดใหม่", + "clear_button": "ล้าง…", + "clear_confirm_title": "ล้างบัญชีนี้บนอุปกรณ์นี้ไหม", + "clear_confirm_message": "ทุกอย่างที่อุปกรณ์นี้เก็บไว้สำหรับบัญชีนี้จะถูกลบและดาวน์โหลดใหม่ อีเมลของคุณบนเซิร์ฟเวอร์ยังอยู่", + "clear_confirm_unsent": "{count, plural, other {ข้อความที่ยังไม่ได้ส่ง # ข้อความจะสูญหาย}}", + "clear_confirm": "ล้าง", + "persist_refused": "เบราว์เซอร์นี้อาจลบอีเมลที่เก็บไว้เมื่อพื้นที่เหลือน้อย การติดตั้งแอปมักช่วยป้องกันได้", + "persist_retry": "ขออีกครั้ง", + "safari_hint": "Safari จะลบข้อมูลของเว็บไซต์หลังจากไม่ได้เข้าชม 7 วัน เพิ่ม Bulwark ลงในหน้าจอโฮมหรือ Dock เพื่อเก็บอีเมลไว้บนอุปกรณ์นี้", + "evicted": "เบราว์เซอร์ลบอีเมลที่เก็บไว้บนอุปกรณ์นี้แล้ว กำลังดาวน์โหลดใหม่", + "evicted_lost": "{count, plural, 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} ไว้ใช้ออฟไลน์" + }, "plugin_pages": { "loading": "กำลังโหลดหน้าเว็บ...", "not_available_title": "หน้านี้ไม่พร้อมใช้งาน", diff --git a/locales/tr/common.json b/locales/tr/common.json index 839601428..1d41f5f66 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5756,6 +5756,61 @@ "toast": "Çevrimdışı yapılan ve uygulanmayan değişiklikler: {count}. Giden kutusu hangileri olduğunu gösterir.", "storage_failed": "Bu cihazda çevrimdışı e-posta için yer kalmadı. Yer açın veya Ayarlar'dan daha azını saklayın." }, + "offlineStorage": { + "keep_description": "E-postalarınızı, takviminizi, kişilerinizi ve seçtiğiniz dosyaları bu tarayıcıda şifreli olarak tutar. Çevrimdışıyken e-postaları okuyabilir, işaretleyebilir ve taşıyabilirsiniz; yazdıklarınız giden kutusunda bekler. Oturumu kapatınca hepsi silinir.", + "window_label": "Ne kadar geriye tutulsun", + "window_description": "Daha eski e-postalar ve takvim etkinlikleri bu cihazdan kaldırılır. Bağlantınız olduğunda yine açabilirsiniz.", + "window_days": "{days, plural, one {# gün} other {# gün}}", + "window_year": "1 yıl", + "window_everything": "Hepsi", + "attachments_label": "Ekleri tut", + "attachments_description": "Tutulan iletilerin ekleri ve çevrimdışı tuttuğunuz dosyalar, toplam en fazla {total}. En eskiler önce silinir.", + "total_locked": "Kuruluşunuz en fazla {size} ek ve dosyaya izin veriyor.", + "attachment_cap_label": "Tutulacak en büyük ek", + "usage_mail": "İletiler ve arama dizini", + "usage_attachments": "Ekler", + "usage_calendar": "Takvim", + "usage_contacts": "Kişiler", + "usage_files": "Çevrimdışı tutulan dosyalar", + "usage_row": "{size} ({count, plural, one {# öğe} other {# öğe}})", + "progress_list": "İletiler listeleniyor", + "progress_bodies": "İletiler indiriliyor", + "progress_threads": "Konuşmalar sıralanıyor", + "progress_files": "Dosyalar indiriliyor", + "progress_other": "Veriler bu cihaza getiriliyor", + "progress_count": "{done} / {total}", + "progress_count_open": "şimdiye kadar {done}", + "progress_left": "yaklaşık {minutes, plural, one {# dakika} other {# dakika}} kaldı", + "clear_label": "Bu hesabı temizle", + "clear_description": "Bu cihazın bu hesap için tuttuğu her şeyi siler, sonra yeniden indirir.", + "clear_button": "Temizle…", + "clear_confirm_title": "Bu hesap bu cihazda temizlensin mi?", + "clear_confirm_message": "Bu cihazın bu hesap için tuttuğu her şey silinir ve yeniden indirilir. Sunucudaki e-postalarınız kalır.", + "clear_confirm_unsent": "{count, plural, one {# gönderilmemiş ileti kaybolacak.} other {# gönderilmemiş ileti kaybolacak.}}", + "clear_confirm": "Temizle", + "persist_refused": "Alan azaldığında bu tarayıcı tutulan e-postaları silebilir. Uygulamayı yüklemek genellikle bunu önler.", + "persist_retry": "Yeniden sor", + "safari_hint": "Safari, 7 gün ziyaret edilmeyen bir sitenin verilerini siler. E-postalarınızı bu cihazda tutmak için Bulwark'ı Ana Ekran'a veya Dock'a ekleyin.", + "evicted": "Tarayıcı, bu cihazda tutulan e-postaları sildi. Yeniden indiriliyor.", + "evicted_lost": "{count, plural, one {# gönderilmemiş ileti veya değişiklik kayboldu.} other {# gönderilmemiş ileti veya değişiklik kayboldu.}}", + "evicted_dismiss": "Tamam", + "storage_full_title": "Cihaz depolama alanı dolu.", + "storage_full_text": "Bulwark artık buraya e-posta kaydetmiyor. Daha az e-posta tutun veya yer açmak için bu hesabı temizleyin.", + "open_settings": "Ayarları aç", + "default_on": "Bulwark, bağlantı olmadan da çalışması için e-postalarınızı bu cihazda tutar. Bunu Ayarlar'dan değiştirebilirsiniz.", + "conflict_calendar": "Takvim etkinliği", + "conflict_contacts": "Kişi", + "kept_files_title": "Çevrimdışı tutulan dosyalar", + "kept_state_kept": "Bu cihazda", + "kept_state_waiting": "İndirilmeyi bekliyor", + "kept_error_tooLarge": "Tutulamayacak kadar büyük", + "kept_error_overLimit": "Bu cihazın sınırını aşıyor", + "kept_error_notAllowed": "Kuruluşunuz dosyaların tutulmasına izin vermiyor", + "kept_error_download": "İndirilemedi", + "keep_offline": "Çevrimdışı tut", + "unpin": "Çevrimdışı tutmayı bırak", + "unpin_label": "{name} dosyasını çevrimdışı tutmayı bırak" + }, "deep_link": { "message_not_found": "Bu ileti artık kullanılamıyor.", "thread_not_found": "Bu sohbet artık kullanılamıyor.", diff --git a/locales/uk/common.json b/locales/uk/common.json index e9ab2cd21..f18e2f092 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5756,6 +5756,61 @@ "toast": "Зміни, внесені офлайн і не застосовані: {count}. Які саме, видно у вихідних.", "storage_failed": "На цьому пристрої не залишилося місця для офлайн-пошти. Звільніть місце або зберігайте менше в налаштуваннях." }, + "offlineStorage": { + "keep_description": "Зберігає вашу пошту, календар, контакти та вибрані файли в зашифрованому вигляді в цьому браузері. Без мережі можна читати, позначати й переміщати листи, а написане чекає у вихідних. Під час виходу все видаляється.", + "window_label": "За який період зберігати", + "window_description": "Старіші листи й події календаря видаляються з цього пристрою. Їх і надалі можна відкрити, коли є мережа.", + "window_days": "{days, plural, one {# день} few {# дні} many {# днів} other {# дня}}", + "window_year": "1 рік", + "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, one {# елемент} 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, one {# хвилина} few {# хвилини} many {# хвилин} other {# хвилини}}", + "clear_label": "Очистити цей обліковий запис", + "clear_description": "Видаляє все, що цей пристрій зберігає для цього облікового запису, і завантажує знову.", + "clear_button": "Очистити…", + "clear_confirm_title": "Очистити цей обліковий запис на цьому пристрої?", + "clear_confirm_message": "Усе, що цей пристрій зберігає для цього облікового запису, буде видалено й завантажено знову. Ваша пошта на сервері залишиться.", + "clear_confirm_unsent": "{count, plural, one {# невідправлений лист буде втрачено.} few {# невідправлені листи буде втрачено.} many {# невідправлених листів буде втрачено.} other {# невідправленого листа буде втрачено.}}", + "clear_confirm": "Очистити", + "persist_refused": "Цей браузер може видалити збережену пошту, коли бракує місця. Встановлення застосунку зазвичай цьому запобігає.", + "persist_retry": "Запитати знову", + "safari_hint": "Safari видаляє дані сайту, якщо його не відвідували 7 днів. Додайте Bulwark на екран «Додому» або в Dock, щоб пошта залишалася на цьому пристрої.", + "evicted": "Браузер видалив пошту, збережену на цьому пристрої. Вона завантажується знову.", + "evicted_lost": "{count, plural, one {Втрачено # невідправлений лист або зміну.} few {Втрачено # невідправлені листи або зміни.} many {Втрачено # невідправлених листів або змін.} other {Втрачено # невідправленого листа або зміни.}}", + "evicted_dismiss": "OK", + "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} без мережі" + }, "deep_link": { "message_not_found": "Цей лист більше недоступний.", "thread_not_found": "Ця розмова більше недоступна.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 2c242d359..a1b0f4125 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5768,6 +5768,61 @@ "toast": "Thay đổi khi ngoại tuyến chưa được áp dụng: {count}. Hộp thư đi cho biết là những thay đổi nào.", "storage_failed": "Thiết bị này không còn chỗ cho thư ngoại tuyến. Hãy giải phóng dung lượng hoặc giữ ít hơn trong Cài đặt." }, + "offlineStorage": { + "keep_description": "Giữ thư, lịch, danh bạ và các tệp bạn chọn, được mã hóa trong trình duyệt này. Khi ngoại tuyến bạn có thể đọc, gắn cờ và di chuyển thư, còn thư bạn viết sẽ chờ trong Hộp thư đi. Khi đăng xuất, tất cả sẽ bị xóa.", + "window_label": "Giữ lại bao lâu", + "window_description": "Thư và sự kiện lịch cũ hơn sẽ bị xóa khỏi thiết bị này. Bạn vẫn có thể mở chúng khi có kết nối.", + "window_days": "{days, plural, other {# ngày}}", + "window_year": "1 năm", + "window_everything": "Tất cả", + "attachments_label": "Giữ tệp đính kèm", + "attachments_description": "Tệp đính kèm của các thư được giữ và các tệp bạn giữ ngoại tuyến, tối đa {total} tổng cộng. Tệp cũ nhất sẽ bị xóa trước.", + "total_locked": "Tổ chức của bạn chỉ cho phép tối đa {size} tệp đính kèm và tệp.", + "attachment_cap_label": "Tệp đính kèm lớn nhất được giữ", + "usage_mail": "Thư và chỉ mục tìm kiếm", + "usage_attachments": "Tệp đính kèm", + "usage_calendar": "Lịch", + "usage_contacts": "Danh bạ", + "usage_files": "Tệp giữ ngoại tuyến", + "usage_row": "{size} ({count, plural, other {# mục}})", + "progress_list": "Đang liệt kê thư", + "progress_bodies": "Đang tải thư xuống", + "progress_threads": "Đang sắp xếp cuộc trò chuyện", + "progress_files": "Đang tải tệp xuống", + "progress_other": "Đang đưa dữ liệu về thiết bị này", + "progress_count": "{done} trên {total}", + "progress_count_open": "đến nay {done}", + "progress_left": "còn khoảng {minutes, plural, other {# phút}}", + "clear_label": "Xóa tài khoản này", + "clear_description": "Xóa mọi thứ thiết bị này giữ cho tài khoản này, rồi tải lại.", + "clear_button": "Xóa…", + "clear_confirm_title": "Xóa tài khoản này trên thiết bị này?", + "clear_confirm_message": "Mọi thứ thiết bị này giữ cho tài khoản này sẽ bị xóa và tải lại. Thư của bạn trên máy chủ vẫn còn.", + "clear_confirm_unsent": "{count, plural, other {# thư chưa gửi sẽ bị mất.}}", + "clear_confirm": "Xóa", + "persist_refused": "Trình duyệt này có thể xóa thư đã giữ khi sắp hết dung lượng. Cài đặt ứng dụng thường giúp tránh điều này.", + "persist_retry": "Hỏi lại", + "safari_hint": "Safari xóa dữ liệu của một trang web sau 7 ngày không truy cập. Hãy thêm Bulwark vào Màn hình chính hoặc Dock để giữ thư trên thiết bị này.", + "evicted": "Trình duyệt đã xóa thư được giữ trên thiết bị này. Đang tải lại.", + "evicted_lost": "{count, plural, other {# thư hoặc thay đổi chưa gửi đã bị mất.}}", + "evicted_dismiss": "OK", + "storage_full_title": "Bộ nhớ thiết bị đã đầy.", + "storage_full_text": "Bulwark đã ngừng lưu thư ở đây. Hãy giữ ít thư hơn hoặc xóa tài khoản này để có thêm chỗ.", + "open_settings": "Mở cài đặt", + "default_on": "Bulwark giữ thư của bạn trên thiết bị này để dùng được khi không có kết nối. Bạn có thể đổi điều này trong Cài đặt.", + "conflict_calendar": "Sự kiện lịch", + "conflict_contacts": "Liên hệ", + "kept_files_title": "Tệp giữ ngoại tuyến", + "kept_state_kept": "Trên thiết bị này", + "kept_state_waiting": "Đang chờ tải xuống", + "kept_error_tooLarge": "Quá lớn để giữ", + "kept_error_overLimit": "Vượt giới hạn của thiết bị này", + "kept_error_notAllowed": "Tổ chức của bạn không cho phép giữ tệp", + "kept_error_download": "Không tải xuống được", + "keep_offline": "Giữ ngoại tuyến", + "unpin": "Ngừng giữ ngoại tuyến", + "unpin_label": "Ngừng giữ {name} ngoại tuyến" + }, "deep_link": { "message_not_found": "Thư này không còn nữa.", "thread_not_found": "Cuộc trò chuyện này không còn tồn tại nữa.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index dc8d0f4bf..261013184 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5756,6 +5756,61 @@ "toast": "離線時的變更未被套用:{count} 項。可在寄件匣中查看。", "storage_failed": "此裝置已沒有空間存放離線郵件。請釋出空間,或在設定中減少保留的內容。" }, + "offlineStorage": { + "keep_description": "在此瀏覽器中加密保留你的郵件、行事曆、聯絡人和你選擇的檔案。離線時可以閱讀、標記和移動郵件,寫好的郵件會在寄件匣中等待。登出時會全部刪除。", + "window_label": "保留多久以內的內容", + "window_description": "更早的郵件和行事曆活動會從此裝置移除。連線時你仍可以開啟它們。", + "window_days": "{days, plural, other {# 天}}", + "window_year": "1 年", + "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, other {# 項}})", + "progress_list": "正在列出郵件", + "progress_bodies": "正在下載郵件", + "progress_threads": "正在整理對話", + "progress_files": "正在下載檔案", + "progress_other": "正在把資料帶到此裝置", + "progress_count": "{done} / {total}", + "progress_count_open": "已完成 {done}", + "progress_left": "還剩約 {minutes, plural, other {# 分鐘}}", + "clear_label": "清除此帳號", + "clear_description": "刪除此裝置為該帳號保留的全部內容,然後重新下載。", + "clear_button": "清除…", + "clear_confirm_title": "在此裝置上清除該帳號?", + "clear_confirm_message": "此裝置為該帳號保留的全部內容會被刪除並重新下載。伺服器上的郵件不受影響。", + "clear_confirm_unsent": "{count, plural, other {# 封未寄出的郵件將會遺失。}}", + "clear_confirm": "清除", + "persist_refused": "儲存空間不足時,此瀏覽器可能會刪除保留的郵件。安裝應用程式通常可以避免這種情況。", + "persist_retry": "再次請求", + "safari_hint": "Safari 會在網站 7 天未造訪後刪除其資料。把 Bulwark 加入主畫面或 Dock,郵件就能保留在此裝置上。", + "evicted": "瀏覽器刪除了此裝置上保留的郵件。正在重新下載。", + "evicted_lost": "{count, plural, 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}" + }, "deep_link": { "message_not_found": "已無法使用此郵件。", "thread_not_found": "已無法使用此對話串。", diff --git a/locales/zh/common.json b/locales/zh/common.json index bc7da9471..ae36afa3e 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5756,6 +5756,61 @@ "toast": "离线时的更改未被应用:{count} 项。可在发件箱中查看。", "storage_failed": "此设备已没有空间存放离线邮件。请释放空间,或在设置中减少保留的内容。" }, + "offlineStorage": { + "keep_description": "在此浏览器中加密保留你的邮件、日历、联系人和你选择的文件。离线时可以阅读、标记和移动邮件,写好的邮件会在发件箱中等待。退出登录时会全部删除。", + "window_label": "保留多久以内的内容", + "window_description": "更早的邮件和日历事件会从此设备中移除。联网时你仍可以打开它们。", + "window_days": "{days, plural, other {# 天}}", + "window_year": "1 年", + "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, other {# 项}})", + "progress_list": "正在列出邮件", + "progress_bodies": "正在下载邮件", + "progress_threads": "正在整理会话", + "progress_files": "正在下载文件", + "progress_other": "正在把数据带到此设备", + "progress_count": "{done} / {total}", + "progress_count_open": "已完成 {done}", + "progress_left": "还剩约 {minutes, plural, other {# 分钟}}", + "clear_label": "清除此账户", + "clear_description": "删除此设备为该账户保留的全部内容,然后重新下载。", + "clear_button": "清除…", + "clear_confirm_title": "在此设备上清除该账户?", + "clear_confirm_message": "此设备为该账户保留的全部内容会被删除并重新下载。服务器上的邮件不受影响。", + "clear_confirm_unsent": "{count, plural, other {# 封未发送的邮件将丢失。}}", + "clear_confirm": "清除", + "persist_refused": "存储空间不足时,此浏览器可能会删除保留的邮件。安装应用通常可以避免这种情况。", + "persist_retry": "再次请求", + "safari_hint": "Safari 会在网站 7 天未访问后删除其数据。把 Bulwark 添加到主屏幕或程序坞,邮件就能保留在此设备上。", + "evicted": "浏览器删除了此设备上保留的邮件。正在重新下载。", + "evicted_lost": "{count, plural, 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}" + }, "deep_link": { "message_not_found": "此邮件已不可用。", "thread_not_found": "此会话已不可用。", From b4d43d9bbe10339364c4edb2875e29228b0e76a8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:17:57 +0100 Subject: [PATCH 12/35] test: check what a device keeps on both servers, down to the raw bytes --- components/offline/account-storage.tsx | 2 +- integration/tests/22-ent-offline.spec.ts | 290 +++++++++++++++++++++++ 2 files changed, 291 insertions(+), 1 deletion(-) diff --git a/components/offline/account-storage.tsx b/components/offline/account-storage.tsx index e3ebf9c73..f86ed4a8b 100644 --- a/components/offline/account-storage.tsx +++ b/components/offline/account-storage.tsx @@ -128,7 +128,7 @@ export function AccountStorage({ account, primary, heading }: { account: string; {syncing && progressWorthShowing(progress) && } {usage && usage.parts.length > 0 && (
    - {usage.parts.map((part) => ( + {usage.parts.filter((part) => part.items > 0).map((part) => (
    {tStorage(`usage_${part.part}`)}
    {tStorage('usage_row', { size: formatFileSize(part.bytes), count: part.items })}
    diff --git a/integration/tests/22-ent-offline.spec.ts b/integration/tests/22-ent-offline.spec.ts index ff4dcd3cf..694c02931 100644 --- a/integration/tests/22-ent-offline.spec.ts +++ b/integration/tests/22-ent-offline.spec.ts @@ -590,6 +590,296 @@ 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 { + 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 { + 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 { + 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}`; + await importOld(ENT.alice, old, 200); + 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); + await page.goto('/mail?q=' + encodeURIComponent(`"Old message"`)).catch(() => {}); + await expect(page.locator('[data-testid="email-list-item"]', { 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((resolve, reject) => { const r = indexedDB.open('bulwark-store'); r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); }); + const all = await new Promise>((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((resolve, reject) => { const r = indexedDB.open('bulwark-store'); r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); }); + const all = await new Promise>((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); + const overflow = await phone.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(0); + 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; + 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.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((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(); +}); + +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`; + await deliverAndWait(subject, text); + 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((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((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); + const total = await client.request([['Email/query', { accountId: client.accountId, 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); + + for (const term of [subject, text, `${RUN}quokka`, `${RUN}wombat`]) { + 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.reload(); + 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('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)'); From fb40944c2d3a06bf15259843e955deec1a59ed08 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:28:31 +0100 Subject: [PATCH 13/35] feat: hold kept files to the scope at once, and count them towards what each account takes --- lib/store/__tests__/files.test.ts | 69 +++++++++++++++++++++++++++ lib/store/__tests__/mail-sync.test.ts | 2 +- lib/store/contacts/index.ts | 4 ++ lib/store/files/index.ts | 5 +- lib/store/files/keep.ts | 46 +++++++++++++++++- 5 files changed, 123 insertions(+), 3 deletions(-) diff --git a/lib/store/__tests__/files.test.ts b/lib/store/__tests__/files.test.ts index acf81b483..41d158e12 100644 --- a/lib/store/__tests__/files.test.ts +++ b/lib/store/__tests__/files.test.ts @@ -311,6 +311,75 @@ describe('pinned files', () => { 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 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'); diff --git a/lib/store/__tests__/mail-sync.test.ts b/lib/store/__tests__/mail-sync.test.ts index e4424a4a3..4d7b61185 100644 --- a/lib/store/__tests__/mail-sync.test.ts +++ b/lib/store/__tests__/mail-sync.test.ts @@ -443,7 +443,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, keptByOthers: () => 0, 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, requestSync: async () => {}, defaultJmap: JMAP_ACCOUNT, invertedKeywordSort: false }, buildMethodTable([mailArea]), [['Email/query', { filter: INBOX, sort: [{ property: 'receivedAt', isAscending: false }], limit: 5 }, 'q']], ); diff --git a/lib/store/contacts/index.ts b/lib/store/contacts/index.ts index 01b129a8a..8c964775f 100644 --- a/lib/store/contacts/index.ts +++ b/lib/store/contacts/index.ts @@ -9,6 +9,10 @@ export const contactsArea: StoreArea = { 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) => { diff --git a/lib/store/files/index.ts b/lib/store/files/index.ts index 0957ced89..016d4b4f6 100644 --- a/lib/store/files/index.ts +++ b/lib/store/files/index.ts @@ -1,5 +1,5 @@ import type { StoreArea } from '../area'; -import { fileBlob, keptStatus, noteOpened, pinFile, readFile, unpinFile, type NodeRef } from './keep'; +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'; @@ -11,6 +11,9 @@ export const filesArea: StoreArea = { 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), diff --git a/lib/store/files/keep.ts b/lib/store/files/keep.ts index a11b93455..919d7cb97 100644 --- a/lib/store/files/keep.ts +++ b/lib/store/files/keep.ts @@ -13,7 +13,7 @@ */ import { TransportError } from '../engine/transport'; -import { MethodError, type StoreContext, type SyncContext } from '../area'; +import { MethodError, type ScopeChange, type StoreContext, type StoreUsagePart, type SyncContext } from '../area'; import type { StorageAdapter } from '../storage'; import { nodeMediaType, type WireNode } from './records'; @@ -220,6 +220,50 @@ export async function fetchKeptFiles(ctx: SyncContext, jmap: string): Promise 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 || Number(blob.size) <= largest) continue; + 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): 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 { From 87b15b12f6c3258cba3bdaec2bc5458ffba82bf3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:28:31 +0100 Subject: [PATCH 14/35] test: check Keep offline in the Files app --- integration/tests/22-ent-offline.spec.ts | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/integration/tests/22-ent-offline.spec.ts b/integration/tests/22-ent-offline.spec.ts index 694c02931..5e2b6e642 100644 --- a/integration/tests/22-ent-offline.spec.ts +++ b/integration/tests/22-ent-offline.spec.ts @@ -863,6 +863,42 @@ test('nothing of a kept message is readable on the device', async ({ browser }) 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(); From 1dde70399308a66d6467df17bfcd53363f5125c5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:53:04 +0100 Subject: [PATCH 15/35] fix: list files again when the real client takes over from the local copy --- integration/tests/22-ent-offline.spec.ts | 4 +++- stores/__tests__/file-store.test.ts | 20 +++++++++++++++++++- stores/file-store.ts | 6 ++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/integration/tests/22-ent-offline.spec.ts b/integration/tests/22-ent-offline.spec.ts index 5e2b6e642..e9f684b81 100644 --- a/integration/tests/22-ent-offline.spec.ts +++ b/integration/tests/22-ent-offline.spec.ts @@ -842,7 +842,9 @@ test('nothing of a kept message is readable on the device', async ({ browser }) await enableOffline(page); await expect(async () => { const client = await jmapAs(ENT.alice); - const total = await client.request([['Email/query', { accountId: client.accountId, calculateTotal: true, limit: 0 }, '0']]); + // 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); diff --git a/stores/__tests__/file-store.test.ts b/stores/__tests__/file-store.test.ts index bf7080b02..74dbad63c 100644 --- a/stores/__tests__/file-store.test.ts +++ b/stores/__tests__/file-store.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { useFileStore, linkedPreviewResource, resourceServerRef, sharedFilesLocation } from '../file-store'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { FileNode } from '@/lib/jmap/types'; @@ -106,6 +106,24 @@ const file = (id: string, name: string, parentId: string | null): FileNode => ({ id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 10, created: '', modified: '', }); +describe('after a start from the local copy', () => { + it('lists again when the real client takes over, and the error of asking the device goes', async () => { + const local = makeMockClient([]); + (local as unknown as { isOfflineOnly: () => boolean }).isOfflineOnly = () => true; + local.listAllFileNodes = async () => { throw new Error('Not available offline'); }; + useFileStore.setState({ client: null, supportsFiles: null, resources: [], error: null, currentParentId: null, pathStack: [{ id: null, name: '' }] } as never); + useFileStore.getState().initClient(local as unknown as IJMAPClient, 'acc'); + useFileStore.setState({ supportsFiles: true }); + await useFileStore.getState().navigate(null); + expect(useFileStore.getState().error).toBeTruthy(); + + const real = makeMockClient([{ id: 'f1', parentId: null, name: 'kept.txt', type: 'text/plain', blobId: 'b', size: 1, created: '', modified: '' }]); + useFileStore.getState().initClient(real as unknown as IJMAPClient, 'acc'); + await vi.waitFor(() => expect(useFileStore.getState().resources.map((r) => r.name)).toEqual(['kept.txt'])); + expect(useFileStore.getState().error).toBeFalsy(); + }); +}); + describe('file-store hierarchy (issue #379)', () => { beforeEach(() => { useFileStore.setState({ diff --git a/stores/file-store.ts b/stores/file-store.ts index 328635142..fcee224eb 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -649,9 +649,15 @@ export const useFileStore = create((set, get) => ({ })(), initClient: (client: IJMAPClient, accountId?: string | null) => { + const before = get().client as (IJMAPClient & { isOfflineOnly?: () => boolean }) | null; const patch: Partial = { client }; if (accountId !== undefined) patch.currentAccountId = accountId; set(patch); + // A start from the local copy: the real client just took over, and what's + // listed is what the device had, or the error of asking it. + const offlineBefore = before !== client && before?.isOfflineOnly?.(); + const offlineNow = (client as IJMAPClient & { isOfflineOnly?: () => boolean }).isOfflineOnly?.(); + if (offlineBefore && !offlineNow && get().supportsFiles) void get().refresh(); }, clearClient: () => { From a2740fa9e9a510f212ec2e65658087bd5ac3a8f1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:55:38 +0100 Subject: [PATCH 16/35] feat: generate the desktop app's policy templates from one schema --- desktop-policies/Bulwark.admx | 271 ++++++ desktop-policies/README.txt | 10 + desktop-policies/de-DE/Bulwark.adml | 84 ++ desktop-policies/en-US/Bulwark.adml | 84 ++ .../org.bulwarkmail.desktop.mobileconfig | 82 ++ desktop-policies/policies.json | 28 + lib/admin/outgoing-hosts-registry.ts | 3 + lib/desktop/__tests__/policy-schema.test.ts | 301 +++++++ .../__tests__/policy-templates.test.ts | 191 +++++ lib/desktop/policy-schema.ts | 772 ++++++++++++++++++ lib/desktop/policy-templates.ts | 305 +++++++ scripts/generate-desktop-policies.mjs | 47 ++ 12 files changed, 2178 insertions(+) create mode 100644 desktop-policies/Bulwark.admx create mode 100644 desktop-policies/README.txt create mode 100644 desktop-policies/de-DE/Bulwark.adml create mode 100644 desktop-policies/en-US/Bulwark.adml create mode 100644 desktop-policies/org.bulwarkmail.desktop.mobileconfig create mode 100644 desktop-policies/policies.json create mode 100644 lib/desktop/__tests__/policy-schema.test.ts create mode 100644 lib/desktop/__tests__/policy-templates.test.ts create mode 100644 lib/desktop/policy-schema.ts create mode 100644 lib/desktop/policy-templates.ts create mode 100644 scripts/generate-desktop-policies.mjs diff --git a/desktop-policies/Bulwark.admx b/desktop-policies/Bulwark.admx new file mode 100644 index 000000000..391df08c9 --- /dev/null +++ b/desktop-policies/Bulwark.admx @@ -0,0 +1,271 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + stable + beta + + + + + + + + + stable + beta + + + + + + + + + + + + + + + + + + + + + allowed + allowlist + off + + + + + + + + + allowed + allowlist + off + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop-policies/README.txt b/desktop-policies/README.txt new file mode 100644 index 000000000..2e53d392a --- /dev/null +++ b/desktop-policies/README.txt @@ -0,0 +1,10 @@ +Generated by scripts/generate-desktop-policies.mjs. Do not edit these files. + +Windows: copy Bulwark.admx to %SystemRoot%\PolicyDefinitions, and the en-US and +de-DE folders next to it (or to the central store in SYSVOL). In Intune, import +Bulwark.admx and en-US\Bulwark.adml. + +macOS: org.bulwarkmail.desktop.mobileconfig is a sample. Change it and deploy it by MDM. + +Linux: policies.json is a sample for /etc/bulwark/policies.json. It must be owned by root +and writable only by root (mode 644). Otherwise the app refuses to start. diff --git a/desktop-policies/de-DE/Bulwark.adml b/desktop-policies/de-DE/Bulwark.adml new file mode 100644 index 000000000..b9f25c3a9 --- /dev/null +++ b/desktop-policies/de-DE/Bulwark.adml @@ -0,0 +1,84 @@ + + + + Bulwark Desktop + Einstellungen für die Bulwark-Desktop-App. + + + Bulwark + Bulwark Desktop + Bulwark Desktop: Standardwerte, die Benutzer ändern können + Bulwark Desktop 1.0 und höher + Verbindung + Updates + Plugins + Offline-E-Mails und App-Sperre + Datenschutz + Anmeldung und Zertifikate + Bulwark-Server + Die Adresse Ihres Bulwark-Servers, beginnend mit https://. Wenn erzwungen, öffnet sich die App auf diesem Server, die Serverauswahl ist ausgeblendet und das Fenster bleibt auf diesem Server. Eine ungültige Adresse zeigt eine Fehlerseite mit dem Namen dieser Richtlinie, und die App weicht auf nichts anderes aus. Als Standardwert öffnet sich die App auf diesem Server, bis der Benutzer etwas anderes wählt. + Direkte Verbindungen zu Mailservern zulassen + Wenn deaktiviert, öffnet die App nur einen Bulwark-Server, und niemand kann sich direkt bei einem Mailserver anmelden. Früher hinzugefügte Konten bleiben auf dem Datenträger, sind aber ausgeblendet. Wenn Sie einen Bulwark-Server erzwingen und diese Richtlinie nicht konfigurieren, sind direkte Verbindungen ebenfalls aus. Aktivieren Sie sie, um beides anzubieten. + Zugelassene Mailserver + Die Mailserver, bei denen sich Benutzer anmelden dürfen. Einer pro Zeile: eine Adresse wie https://mail.example.com oder https://*.example.com für alle Subdomains. Ein Server ohne Schema bedeutet https. Unverschlüsseltes http muss mit http:// angegeben werden. Ist diese Liste gesetzt, wird alles andere abgelehnt. + Bulwark-Server über unverschlüsseltes http zulassen + Wenn aktiviert, darf die Adresse unter „Bulwark-Server“ mit http:// beginnen. Passwörter und E-Mails gehen dann unverschlüsselt über das Netzwerk, und Funktionen, die https brauchen, wie Offline-E-Mails und S/MIME, funktionieren nicht. Lassen Sie dies aus, sofern der Server nicht in einem Netzwerk steht, dem Sie vertrauen. + Update-Kanal + Auf welche Versionen die App aktualisiert wird. + Stabil + Beta + Automatisch aktualisieren + Wenn deaktiviert, sucht die App nicht nach Updates und lädt keine herunter. Verwenden Sie dies, wenn SCCM, Intune oder ähnliche Werkzeuge die Updates liefern. + Welche Plugins laufen dürfen + Ob Plugins installiert und ausgeführt werden dürfen. Ein abgelehntes Plugin behält seine Daten und Einstellungen: Es kommt unverändert zurück, wenn die Richtlinie es wieder erlaubt. + Alle Plugins sind erlaubt + Nur Plugins auf der Liste + Keine Plugins + Erlaubte Plugins + Die IDs der Plugins, die laufen dürfen, wenn „Welche Plugins laufen dürfen“ auf die Liste gesetzt ist. Eine pro Zeile. + E-Mails auf dem Gerät behalten + Ob Konten eine verschlüsselte Kopie der E-Mails auf diesem Gerät für die Offline-Nutzung behalten. Wenn erzwungen aus, werden bereits vorhandene Kopien entfernt. + App-Sperre verlangen + Wenn aktiviert, müssen Benutzer die App-Sperre einrichten und können sie nicht ausschalten. + App sperren nach + Wie viele Minuten ohne Nutzung die App entsperrt bleibt. Von 1 bis 1440. + Nutzungsstatistiken zulassen + Wenn deaktiviert, sendet die App keine Nutzungsstatistiken und fragt nicht danach. Wenn aktiviert oder nicht konfiguriert, fragt sie den Benutzer trotzdem zuerst. + Absturzberichte zulassen + Wenn deaktiviert, sendet die App keine Absturzberichte und fragt nicht danach. Wenn aktiviert oder nicht konfiguriert, fragt sie den Benutzer trotzdem zuerst. + Globale Tastenkombination für neue Nachricht + Die Tastenkombination, die von überall aus eine neue Nachricht öffnet, etwa CommandOrControl+Alt+N. Geben Sie off ein, um sie auszuschalten. + Server für die integrierte Anmeldung + Die Server, die die Windows-Anmeldung (Kerberos, NTLM) verwenden dürfen, durch Kommas getrennt, etwa *.example.com. Wird beim Neustart der App wirksam. + Server für die delegierte Anmeldung + Die Server, an die die App Kerberos-Anmeldedaten delegieren darf, durch Kommas getrennt. Wird beim Neustart der App wirksam. + Clientzertifikate automatisch wählen + Eine JSON-Regel pro Zeile, im Format von Chrome: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. Die App wählt dann für diese Website das passende Zertifikat ohne Rückfrage. + Auf dem Gerät behaltene E-Mails löschen + Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alle E-Mails zu löschen, die die App auf dem Gerät behält. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen. + + + + + Zugelassene Mailserver + Zugelassene Mailserver + Update-Kanal + Update-Kanal + Welche Plugins laufen dürfen + Welche Plugins laufen dürfen + Plugin-IDs + Plugin-IDs + Minuten + Minuten + + + + + + + Regeln + Regeln + + + + diff --git a/desktop-policies/en-US/Bulwark.adml b/desktop-policies/en-US/Bulwark.adml new file mode 100644 index 000000000..1bf30bacf --- /dev/null +++ b/desktop-policies/en-US/Bulwark.adml @@ -0,0 +1,84 @@ + + + + Bulwark Desktop + Settings for the Bulwark desktop app. + + + Bulwark + Bulwark Desktop + Bulwark Desktop: defaults users can change + Bulwark Desktop 1.0 and later + Connection + Updates + Plugins + Offline mail and app lock + Privacy + Sign-in and certificates + Bulwark server + The address of your Bulwark server, starting with https://. When forced, the app opens on this server, the choice of server is hidden, and the window stays on it. An invalid address shows an error page that names this policy, and the app does not fall back to anything else. As a default, the app opens on this server until the user picks something else. + Allow direct connections to mail servers + When disabled, the app only opens a Bulwark server and nobody can sign in directly to a mail server. Accounts added earlier stay on the disk, but are hidden. If you force a Bulwark server and leave this not configured, direct connections are off too. Enable it to offer both. + Allowed mail servers + The mail servers people may sign in to. One per line: an address such as https://mail.example.com, or https://*.example.com for all subdomains. A server without a scheme means https. Plain http must be named with http://. When this list is set, everything else is refused. + Allow a Bulwark server on plain http + When enabled, the address in "Bulwark server" may start with http://. Passwords and mail then travel unencrypted, and the browser features that need https, such as offline mail and S/MIME, do not work. Leave this off unless the server is on a network you trust. + Update channel + Which releases the app updates to. + Stable + Beta + Update automatically + When disabled, the app does not look for updates or download them. Use this when SCCM, Intune or similar tools deliver updates. + Which plugins may run + Whether plugins can be installed and run. A plugin that is refused keeps its data and settings: it comes back as it was if the policy allows it again. + All plugins are allowed + Only plugins on the list + No plugins + Allowed plugins + The ids of the plugins that may run when "Which plugins may run" is set to the list. One per line. + Keep mail on the device + Whether accounts keep an encrypted copy of mail on this device for offline use. When forced off, copies already on the device are removed. + Require an app lock + When enabled, people must set up the app lock and cannot turn it off. + Lock the app after + How many minutes without use the app stays unlocked. From 1 to 1440. + Allow usage statistics + When disabled, the app sends no usage statistics and does not ask. When enabled or not configured, it still asks the user first. + Allow crash reports + When disabled, the app sends no crash reports and does not ask. When enabled or not configured, it still asks the user first. + Global shortcut for a new message + The key combination that opens a new message from anywhere, such as CommandOrControl+Alt+N. Enter off to turn the shortcut off. + Servers for integrated sign-in + The servers that may use the Windows sign-in (Kerberos, NTLM), separated by commas, such as *.example.com. Takes effect when the app restarts. + Servers for delegated sign-in + The servers the app may delegate Kerberos credentials to, separated by commas. Takes effect when the app restarts. + Choose client certificates automatically + One JSON rule per line, in the format Chrome uses: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. The app then picks the matching certificate for that site without asking. + Delete mail kept on the device + Set this to a new value, such as today's date, to delete all mail the app keeps on the device. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again. + + + + + Allowed mail servers + Allowed mail servers + Update channel + Update channel + Which plugins may run + Which plugins may run + Plugin ids + Plugin ids + Minutes + Minutes + + + + + + + Rules + Rules + + + + diff --git a/desktop-policies/org.bulwarkmail.desktop.mobileconfig b/desktop-policies/org.bulwarkmail.desktop.mobileconfig new file mode 100644 index 000000000..fdb1b400f --- /dev/null +++ b/desktop-policies/org.bulwarkmail.desktop.mobileconfig @@ -0,0 +1,82 @@ + + + + + + PayloadContent + + + PayloadType + org.bulwarkmail.desktop + PayloadVersion + 1 + PayloadIdentifier + org.bulwarkmail.desktop.policy.settings + PayloadUUID + 5E80E0D3-CC43-4BF3-85BB-594992C34B20 + PayloadEnabled + + PayloadDisplayName + Bulwark settings + InstanceUrl + https://mail.example.com + AllowStandalone + + AllowedJmapServers + + https://mail.example.com + https://*.mail.example.com + + AllowInsecureInstance + + AutoUpdate + + PluginsMode + allowlist + PluginAllowlist + + example-plugin + + OfflineEnabled + + AppLockRequired + + AppLockTimeoutMinutes + 10 + TelemetryAllowed + + CrashReportsAllowed + + QuickComposeShortcut + CommandOrControl+Alt+N + AuthServerAllowlist + *.example.com + AuthNegotiateDelegateAllowlist + *.example.com + AutoSelectCertificateForUrls + + {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}} + + Recommended + + UpdateChannel + stable + + + + PayloadType + Configuration + PayloadVersion + 1 + PayloadIdentifier + org.bulwarkmail.desktop.policy + PayloadUUID + AEC7BA2B-DD06-4BDF-83E5-147573C181F4 + PayloadDisplayName + Bulwark Desktop + PayloadDescription + A sample profile. Remove what you do not want to set, and change the rest. + PayloadScope + System + + diff --git a/desktop-policies/policies.json b/desktop-policies/policies.json new file mode 100644 index 000000000..3c9113c58 --- /dev/null +++ b/desktop-policies/policies.json @@ -0,0 +1,28 @@ +{ + "InstanceUrl": "https://mail.example.com", + "AllowStandalone": false, + "AllowedJmapServers": [ + "https://mail.example.com", + "https://*.mail.example.com" + ], + "AllowInsecureInstance": false, + "AutoUpdate": false, + "PluginsMode": "allowlist", + "PluginAllowlist": [ + "example-plugin" + ], + "OfflineEnabled": true, + "AppLockRequired": true, + "AppLockTimeoutMinutes": 10, + "TelemetryAllowed": false, + "CrashReportsAllowed": false, + "QuickComposeShortcut": "CommandOrControl+Alt+N", + "AuthServerAllowlist": "*.example.com", + "AuthNegotiateDelegateAllowlist": "*.example.com", + "AutoSelectCertificateForUrls": [ + "{\"pattern\":\"https://[*.]example.com\",\"filter\":{\"ISSUER\":{\"CN\":\"Example CA\"}}}" + ], + "Recommended": { + "UpdateChannel": "stable" + } +} diff --git a/lib/admin/outgoing-hosts-registry.ts b/lib/admin/outgoing-hosts-registry.ts index dae391d33..053a8df61 100644 --- a/lib/admin/outgoing-hosts-registry.ts +++ b/lib/admin/outgoing-hosts-registry.ts @@ -157,6 +157,8 @@ export const OUTGOING_HOSTS: readonly OutgoingHost[] = [ * has to be looked at again. */ export const NON_REQUEST_HOSTS: readonly { host: string; files: readonly string[]; why: string }[] = [ + { host: 'schemas.microsoft.com', why: 'XML namespace of the ADMX templates', files: ['lib/desktop/policy-templates.ts'] }, + { host: 'www.apple.com', why: 'DTD named in the sample .mobileconfig', files: ['lib/desktop/policy-templates.ts'] }, { host: 'bulwarkmail.org', why: 'docs links, comments and the contact URL in User-Agent headers', files: ['app/(main)/admin/_pages/telemetry.tsx', 'app/api/fetch-ical/route.ts', 'app/api/translate/route.ts', 'components/admin/org/sections/recovery-section.tsx', 'components/admin/org/sections/company-calendars-section.tsx', 'components/admin/org/sections/free-busy-section.tsx', 'components/admin/org/sections/labels-section.tsx', 'components/admin/org/sections/out-of-office-calendar-section.tsx', 'components/admin/org/sections/phishing-report-section.tsx', 'instrumentation.node.ts', 'lib/telemetry/state.ts', 'lib/telemetry/types.ts', 'app/(main)/admin/_pages/monitoring.tsx', 'lib/admin/types.ts', 'lib/error-reporting/client.ts', 'lib/error-reporting/lite.ts', 'lib/error-reporting/scrub.ts', 'instrumentation.ts', 'app/api/health/live/route.ts', 'app/api/health/ready/route.ts', 'app/api/metrics/route.ts', 'lib/metrics/index.ts', 'lib/tracing/config.ts', 'lib/tracing/scrub.ts', 'lib/tracing/start.ts'] }, { host: 'bulwarkmail.org', why: 'docs links of the Notices, Devices (offline mode too) and out-of-office template pages', files: ['components/admin/org/sections/announcement-section.tsx', 'components/admin/org/sections/devices-section.tsx', 'components/admin/org/sections/offline-section.tsx', 'components/admin/org/sections/retention-section.tsx', 'components/admin/org/sections/vacation-templates-section.tsx'] }, { host: 'bulwarkmail.com', why: 'JMAP capability URI', files: ['lib/jmap/client.ts'] }, @@ -182,6 +184,7 @@ export const NON_REQUEST_HOSTS: readonly { host: string; files: readonly string[ * listed with the reason. */ export const BUILT_URL_FILES: readonly { file: string; why: string }[] = [ + { file: 'lib/desktop/policy-schema.ts', why: 'parses a host out of a policy value to check it, and fetches nothing' }, { file: 'app/(main)/admin/_pages/marketplace-detail.tsx', why: "link to an extension's source host, from the directory's answer" }, { file: 'app/(main)/setup/page.tsx', why: "the instance's own origin, and scheme checks of the admin-entered server URL" }, { file: 'app/api/plugin-secrets/route.ts', why: "scheme check of a credential's server URL" }, diff --git a/lib/desktop/__tests__/policy-schema.test.ts b/lib/desktop/__tests__/policy-schema.test.ts new file mode 100644 index 000000000..be7261483 --- /dev/null +++ b/lib/desktop/__tests__/policy-schema.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_POLICY_SNAPSHOT, + EMPTY_POLICY_LAYERS, + POLICY_KEYS, + POLICY_MAX_LIST_ITEMS, + POLICY_SCHEMA, + certificateMatchesRule, + certificateRuleMatchesUrl, + effectivePolicyValue, + jmapServerAllowed, + parseCertificateRule, + parseInstanceUrl, + parsePolicyValue, + parseServerPattern, + pluginAllowedByPolicy, + resolvePolicies, + serverPolicyOf, + type PolicyLayers, +} from '../policy-schema'; + +const layers = (partial: Partial): PolicyLayers => ({ ...EMPTY_POLICY_LAYERS, ...partial }); + +describe('the schema', () => { + it('has every key the build asked for, with text in both languages', () => { + expect(POLICY_KEYS).toEqual([ + 'InstanceUrl', 'AllowStandalone', 'AllowedJmapServers', 'AllowInsecureInstance', 'UpdateChannel', 'AutoUpdate', + 'PluginsMode', 'PluginAllowlist', 'OfflineEnabled', 'AppLockRequired', 'AppLockTimeoutMinutes', 'TelemetryAllowed', + 'CrashReportsAllowed', 'QuickComposeShortcut', 'AuthServerAllowlist', 'AuthNegotiateDelegateAllowlist', + 'AutoSelectCertificateForUrls', 'WipeOfflineData', + ]); + for (const key of POLICY_KEYS) { + const { title, description } = POLICY_SCHEMA[key]; + expect(title.en && title.de && description.en && description.de, key).toBeTruthy(); + expect(description.en, key).not.toBe(description.de); + } + }); +}); + +describe('precedence', () => { + it('forced machine > forced user > recommended machine > recommended user > default', () => { + const all = { + forcedMachine: { UpdateChannel: 'beta' }, + forcedUser: { UpdateChannel: 'stable', AutoUpdate: 0 }, + recommendedMachine: { UpdateChannel: 'stable', AutoUpdate: 1, AppLockTimeoutMinutes: 5 }, + recommendedUser: { AppLockTimeoutMinutes: 9, QuickComposeShortcut: 'Ctrl+Alt+M' }, + }; + const snapshot = resolvePolicies(all); + expect(snapshot.UpdateChannel).toMatchObject({ value: 'beta', source: 'forced-machine', locked: true }); + expect(snapshot.AutoUpdate).toMatchObject({ value: false, source: 'forced-user', locked: true }); + expect(snapshot.AppLockTimeoutMinutes).toMatchObject({ value: 5, source: 'recommended-machine', locked: false }); + expect(snapshot.QuickComposeShortcut).toMatchObject({ value: 'Ctrl+Alt+M', source: 'recommended-user', locked: false }); + expect(snapshot.PluginsMode).toMatchObject({ value: 'allowed', source: 'default', locked: false }); + }); + + it('the user\'s choice sits under forced and over recommended', () => { + const snapshot = resolvePolicies(layers({ forcedUser: { AutoUpdate: 0 }, recommendedMachine: { OfflineEnabled: 0 } })); + expect(effectivePolicyValue(snapshot.AutoUpdate, true)).toBe(false); + expect(effectivePolicyValue(snapshot.OfflineEnabled, true)).toBe(true); + expect(effectivePolicyValue(snapshot.OfflineEnabled, null)).toBe(false); + expect(effectivePolicyValue(snapshot.TelemetryAllowed, undefined)).toBe(true); + }); + + it('takes a whole list from the strongest level and doesn\'t add them up', () => { + const snapshot = resolvePolicies(layers({ + forcedMachine: { AllowedJmapServers: ['https://a.example'] }, + forcedUser: { AllowedJmapServers: ['https://b.example'] }, + })); + expect(snapshot.AllowedJmapServers.value).toEqual(['https://a.example']); + }); + + it('a wipe can only be forced', () => { + expect(resolvePolicies(layers({ recommendedMachine: { WipeOfflineData: 'x' } })).WipeOfflineData.value).toBeNull(); + expect(resolvePolicies(layers({ forcedUser: { WipeOfflineData: 'x' } })).WipeOfflineData).toMatchObject({ value: 'x', locked: true }); + }); +}); + +describe('hostile values fail closed', () => { + const junk: unknown[] = [{}, [], null, 'a'.repeat(100_000), Number.NaN, { toString: 'x' }, ['x'], -1]; + + it.each(POLICY_KEYS)('%s: junk never throws, and never comes out wider than it went in', (key) => { + for (const raw of junk) { + const snapshot = resolvePolicies(layers({ forcedMachine: { [key]: raw } })); + expect(snapshot[key].locked).toBe(true); + } + }); + + it('forced values that make no sense take the strict side, and a weaker level never speaks instead', () => { + const snapshot = resolvePolicies(layers({ + forcedMachine: { AllowStandalone: 'maybe', PluginsMode: 'banana', OfflineEnabled: 7, TelemetryAllowed: 'yes', AppLockRequired: 'no', AllowedJmapServers: 'https://a.example', QuickComposeShortcut: 'rm -rf' }, + recommendedMachine: { AllowStandalone: 1, PluginsMode: 'allowed' }, + })); + expect(snapshot.AllowStandalone).toMatchObject({ value: false, locked: true, invalid: 'not_a_switch' }); + expect(snapshot.PluginsMode).toMatchObject({ value: 'off', locked: true, invalid: 'unknown_value' }); + expect(snapshot.OfflineEnabled.value).toBe(false); + expect(snapshot.TelemetryAllowed.value).toBe(false); + expect(snapshot.AppLockRequired.value).toBe(true); + // A list that isn't one allows nothing, rather than everything. + expect(snapshot.AllowedJmapServers).toMatchObject({ value: [], invalid: 'not_a_list' }); + expect(snapshot.QuickComposeShortcut.value).toBe('off'); + }); + + it('a recommendation that makes no sense is simply not one', () => { + const snapshot = resolvePolicies(layers({ recommendedMachine: { PluginsMode: 'banana' }, recommendedUser: { PluginsMode: 'off' } })); + expect(snapshot.PluginsMode).toMatchObject({ value: 'off', source: 'recommended-user', locked: false }); + expect(snapshot.PluginsMode.invalid).toBeUndefined(); + }); + + it('a list over the cap is refused as a whole, and bad entries drop out one by one', () => { + const tooLong = Array.from({ length: POLICY_MAX_LIST_ITEMS + 1 }, (_, i) => `plugin-${i}`); + expect(resolvePolicies(layers({ forcedMachine: { PluginAllowlist: tooLong } })).PluginAllowlist).toMatchObject({ value: [], invalid: 'too_many' }); + expect(resolvePolicies(layers({ forcedMachine: { PluginAllowlist: ['good', '../evil', 7, 'also-good'] } })).PluginAllowlist).toMatchObject({ value: ['good', 'also-good'], dropped: 2 }); + }); + + it('keeps what was set for the error page, capped', () => { + const entry = resolvePolicies(layers({ forcedMachine: { InstanceUrl: `ftp://${'a'.repeat(500)}` } })).InstanceUrl; + expect(entry.invalid).toBe('scheme'); + expect(entry.raw!.length).toBeLessThanOrEqual(201); + }); + + it('an out of range timeout falls to no value, not to a huge one', () => { + const entry = resolvePolicies(layers({ forcedMachine: { AppLockTimeoutMinutes: 999999 } })).AppLockTimeoutMinutes; + expect(entry).toMatchObject({ value: null, invalid: 'out_of_range' }); + }); +}); + +describe('InstanceUrl', () => { + it.each([ + ['https://mail.example.com', 'https://mail.example.com/'], + [' https://mail.example.com/ ', 'https://mail.example.com/'], + ['https://Mail.Example.com:8443/bulwark//', 'https://mail.example.com:8443/bulwark/'], + ])('%s is fine', (raw, value) => { + expect(parseInstanceUrl(raw, false)).toEqual({ ok: true, value }); + }); + + it.each([ + ['http://mail.example.com', 'insecure'], + ['file:///c:/x', 'scheme'], + ['javascript:alert(1)', 'scheme'], + ['data:text/html,x', 'scheme'], + ['https://user:pw@mail.example.com', 'invalid_url'], + ['https://mail.example.com/?x=1', 'invalid_url'], + ['https://mail.example.com/#x', 'invalid_url'], + ['not a url', 'invalid_url'], + ['', 'empty'], + [`https://${'a'.repeat(3000)}.com`, 'too_long'], + ['https://exa\nmple.com', 'invalid_url'], + [42, 'not_text'], + ])('%s is refused', (raw, reason) => { + expect(parseInstanceUrl(raw, false)).toEqual({ ok: false, reason }); + }); + + it('allows http only with AllowInsecureInstance, resolved from the policies', () => { + expect(resolvePolicies(layers({ forcedMachine: { InstanceUrl: 'http://bulwark.lan' } })).InstanceUrl).toMatchObject({ value: null, invalid: 'insecure' }); + expect(resolvePolicies(layers({ forcedMachine: { InstanceUrl: 'http://bulwark.lan', AllowInsecureInstance: 1 } })).InstanceUrl).toMatchObject({ value: 'http://bulwark.lan/' }); + expect(parseInstanceUrl('file:///x', true).ok).toBe(false); + }); +}); + +describe('AllowedJmapServers', () => { + it.each([ + ['https://mail.example.com', 'https://mail.example.com'], + ['mail.example.com', 'https://mail.example.com'], + ['https://mail.example.com/', 'https://mail.example.com'], + ['http://10.0.0.5:8080', 'http://10.0.0.5:8080'], + ['https://*.example.com', 'https://*.example.com'], + ['*.example.com', 'https://*.example.com'], + ['HTTPS://Mail.Example.COM', 'https://mail.example.com'], + ])('%s becomes %s', (raw, pattern) => { + expect(parseServerPattern(raw)).toBe(pattern); + }); + + it.each(['*', 'https://*', 'https://*.com', 'ftp://a.example', 'https://a.example/path', 'https://u:p@a.example', 'https://a.*.example', 'a b', '', 3])('%s is refused', (raw) => { + expect(parseServerPattern(raw)).toBeNull(); + }); + + it('matches by scheme, host and port, and a wildcard only covers subdomains', () => { + const list = ['https://mail.example.com', 'https://*.corp.example']; + expect(jmapServerAllowed(list, 'https://mail.example.com/jmap/session')).toBe(true); + expect(jmapServerAllowed(list, 'http://mail.example.com')).toBe(false); + expect(jmapServerAllowed(list, 'https://mail.example.com:8443')).toBe(false); + expect(jmapServerAllowed(list, 'https://a.corp.example')).toBe(true); + expect(jmapServerAllowed(list, 'https://corp.example')).toBe(false); + expect(jmapServerAllowed(list, 'https://evilcorp.example')).toBe(false); + expect(jmapServerAllowed(list, 'https://mail.example.com.evil.test')).toBe(false); + expect(jmapServerAllowed(list, 'https://user:pw@mail.example.com')).toBe(false); + expect(jmapServerAllowed(list, 42)).toBe(false); + expect(jmapServerAllowed(list, 'nonsense')).toBe(false); + }); + + it('no list allows everything, an empty list nothing', () => { + expect(jmapServerAllowed(null, 'https://anything.example')).toBe(true); + expect(jmapServerAllowed([], 'https://anything.example')).toBe(false); + }); +}); + +describe('plugins', () => { + it('allowed lets anything through, allowlist only what is listed, off nothing', () => { + expect(pluginAllowedByPolicy('allowed', [], 'x')).toBe(true); + expect(pluginAllowedByPolicy('allowlist', ['x'], 'x')).toBe(true); + expect(pluginAllowedByPolicy('allowlist', ['x'], 'X')).toBe(true); + expect(pluginAllowedByPolicy('allowlist', ['x'], 'y')).toBe(false); + expect(pluginAllowedByPolicy('allowlist', [], 'x')).toBe(false); + expect(pluginAllowedByPolicy('off', ['x'], 'x')).toBe(false); + expect(pluginAllowedByPolicy('allowlist', ['x'], undefined)).toBe(false); + }); +}); + +describe('sign-in lists', () => { + it('passes names and wildcards through, without spaces', () => { + expect(parsePolicyValue('AuthServerAllowlist', '*.example.com, intranet')).toEqual({ ok: true, value: '*.example.com,intranet' }); + }); + + it.each(['*', '*.example.com,*', 'a b', '--no-sandbox x', 'a"b', 'a;b', ''])('%s is refused', (raw) => { + expect(parsePolicyValue('AuthServerAllowlist', raw).ok).toBe(false); + }); + + it('a forced refused list isn\'t applied at all', () => { + expect(resolvePolicies(layers({ forcedMachine: { AuthServerAllowlist: '*' } })).AuthServerAllowlist).toMatchObject({ value: null, invalid: 'invalid_host_list', locked: true }); + }); +}); + +describe('QuickComposeShortcut', () => { + it.each(['Ctrl+Alt+N', 'CommandOrControl+Shift+M', 'Alt+F4'])('%s is a shortcut', (raw) => { + expect(parsePolicyValue('QuickComposeShortcut', raw)).toEqual({ ok: true, value: raw }); + }); + it.each(['off', 'OFF', ''])('"%s" turns it off', (raw) => { + expect(parsePolicyValue('QuickComposeShortcut', raw)).toEqual({ ok: true, value: 'off' }); + }); + it.each(['N', 'Ctrl+', 'Ctrl+Alt+Banana', 'Ctrl+Alt', 7])('%s is refused', (raw) => { + expect(parsePolicyValue('QuickComposeShortcut', raw).ok).toBe(false); + }); +}); + +describe('WipeOfflineData', () => { + it('takes text and numbers, and empty is nobody asking', () => { + expect(parsePolicyValue('WipeOfflineData', '2026-11-03')).toEqual({ ok: true, value: '2026-11-03' }); + expect(parsePolicyValue('WipeOfflineData', 7)).toEqual({ ok: true, value: '7' }); + expect(resolvePolicies(layers({ forcedMachine: { WipeOfflineData: ' ' } })).WipeOfflineData).toMatchObject({ value: null, locked: false }); + expect(resolvePolicies(layers({ forcedMachine: { WipeOfflineData: 'x'.repeat(129) } })).WipeOfflineData.value).toBeNull(); + }); +}); + +describe('AutoSelectCertificateForUrls', () => { + const rule = (pattern: unknown, filter?: unknown) => JSON.stringify({ pattern, filter }); + + it('reads Chrome\'s format', () => { + expect(parseCertificateRule('{"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}')).toEqual({ + host: 'example.com', subdomains: true, issuer: { CN: 'Example CA' }, + }); + expect(parseCertificateRule(rule('https://mail.example.com:8443', { SUBJECT: { O: 'Acme' } }))).toEqual({ + host: 'mail.example.com:8443', subdomains: false, subject: { O: 'Acme' }, + }); + expect(parseCertificateRule(rule('example.com'))).toEqual({ host: 'example.com', subdomains: false }); + }); + + it.each([ + ['not json'], + ['[]'], + ['null'], + [rule('*')], + [rule('https://*')], + [rule('http://example.com')], + [rule('https://example.com/path')], + [rule('https://exa mple.com')], + [rule(7)], + [rule('https://example.com', 'x')], + [rule('https://example.com', { ISSUER: 'x' })], + [rule('https://example.com', { ISSUER: { CN: 7 } })], + [JSON.stringify({ pattern: `https://${'a'.repeat(5000)}.com` })], + ])('%s is refused', (raw) => { + expect(parseCertificateRule(raw)).toBeNull(); + }); + + it('matches https only, and subdomains only when the rule says so', () => { + const wide = parseCertificateRule(rule('[*.]example.com'))!; + const exact = parseCertificateRule(rule('example.com'))!; + expect(certificateRuleMatchesUrl(wide, 'https://a.example.com/x')).toBe(true); + expect(certificateRuleMatchesUrl(wide, 'https://example.com')).toBe(true); + expect(certificateRuleMatchesUrl(exact, 'https://a.example.com')).toBe(false); + expect(certificateRuleMatchesUrl(wide, 'http://a.example.com')).toBe(false); + expect(certificateRuleMatchesUrl(wide, 'https://aexample.com')).toBe(false); + expect(certificateRuleMatchesUrl(wide, 'garbage')).toBe(false); + }); + + it('picks by issuer and subject', () => { + const parsed = parseCertificateRule(rule('example.com', { ISSUER: { CN: 'Ca', O: 'Org' }, SUBJECT: { OU: 'Unit' } }))!; + const good = { issuer: { commonName: 'Ca', organizations: ['Org'] }, subject: { organizationUnits: ['Unit'] } }; + expect(certificateMatchesRule(parsed, good)).toBe(true); + expect(certificateMatchesRule(parsed, { ...good, issuer: { commonName: 'Other', organizations: ['Org'] } })).toBe(false); + expect(certificateMatchesRule(parsed, { ...good, subject: {} })).toBe(false); + expect(certificateMatchesRule(parseCertificateRule(rule('example.com'))!, {})).toBe(true); + }); +}); + +describe('defaults', () => { + it('nothing set means nothing locked and nothing restricted', () => { + for (const key of POLICY_KEYS) expect(DEFAULT_POLICY_SNAPSHOT[key]).toMatchObject({ source: 'default', locked: false }); + expect(serverPolicyOf(DEFAULT_POLICY_SNAPSHOT)).toEqual({ pluginsMode: 'allowed', pluginAllowlist: [], allowedJmapServers: null }); + }); +}); diff --git a/lib/desktop/__tests__/policy-templates.test.ts b/lib/desktop/__tests__/policy-templates.test.ts new file mode 100644 index 000000000..ee4c5b8ee --- /dev/null +++ b/lib/desktop/__tests__/policy-templates.test.ts @@ -0,0 +1,191 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { POLICY_KEYS, POLICY_RECOMMENDED_SUBKEY, POLICY_REGISTRY_ROOT, POLICY_SCHEMA, parsePolicyValue, resolvePolicies, type PolicyKey } from '../policy-schema'; +import { generateAdml, generateAdmx, generateMobileconfig, generatePoliciesJson, generatePolicyFiles } from '../policy-templates'; + +const NS = 'http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions'; +const OUT = path.join(process.cwd(), 'desktop-policies'); + +function parseXml(text: string): Document { + const doc = new DOMParser().parseFromString(text, 'application/xml'); + const error = doc.querySelector('parsererror'); + if (error) throw new Error(`not well-formed: ${error.textContent}`); + return doc; +} + +function all(doc: Document | Element, name: string): Element[] { + return Array.from(doc.getElementsByTagNameNS(NS, name)); +} + +function direct(element: Element, name: string): Element[] { + return Array.from(element.children).filter((child) => child.localName === name); +} + +describe('the committed templates', () => { + it('are what the schema generates', () => { + for (const [name, content] of Object.entries(generatePolicyFiles())) { + const file = path.join(OUT, name); + expect(existsSync(file), `${name} is missing: run node scripts/generate-desktop-policies.mjs`).toBe(true); + expect(readFileSync(file, 'utf8').replace(/\r\n/g, '\n'), `${name} differs: run node scripts/generate-desktop-policies.mjs`).toBe(content); + } + }); + + it('has no file the generator does not write', () => { + const found: string[] = []; + const walk = (dir: string, prefix: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) walk(path.join(dir, entry.name), `${prefix}${entry.name}/`); + else found.push(`${prefix}${entry.name}`); + } + }; + walk(OUT, ''); + expect(found.sort()).toEqual(Object.keys(generatePolicyFiles()).sort()); + }); +}); + +describe('the ADMX', () => { + const doc = parseXml(generateAdmx()); + const root = doc.documentElement; + + it('has the root, namespace and revisions Group Policy needs', () => { + expect(root.localName).toBe('policyDefinitions'); + expect(root.namespaceURI).toBe(NS); + expect(root.getAttribute('revision')).toMatch(/^\d+\.\d+$/); + expect(root.getAttribute('schemaVersion')).toBe('1.0'); + expect(Array.from(root.children).map((child) => child.localName)).toEqual(['policyNamespaces', 'resources', 'supportedOn', 'categories', 'policies']); + const target = all(doc, 'target')[0]; + expect(target.getAttribute('prefix')).toBeTruthy(); + expect(target.getAttribute('namespace')).toBe('Bulwark.Policies.Desktop'); + expect(all(doc, 'resources')[0].getAttribute('minRequiredRevision')).toBe('1.0'); + }); + + it('every policy has a real parent category and a supportedOn that exists', () => { + const categories = new Set(all(doc, 'category').map((c) => c.getAttribute('name'))); + const supported = new Set(all(doc, 'definition').map((d) => d.getAttribute('name'))); + expect(supported.size).toBe(1); + for (const category of all(doc, 'category')) { + for (const parent of direct(category, 'parentCategory')) expect(categories.has(parent.getAttribute('ref'))).toBe(true); + } + const policies = all(doc, 'policy'); + expect(policies.length).toBeGreaterThan(POLICY_KEYS.length); + for (const policy of policies) { + const name = policy.getAttribute('name'); + expect(categories.has(direct(policy, 'parentCategory')[0]?.getAttribute('ref')), `${name} category`).toBe(true); + expect(supported.has(direct(policy, 'supportedOn')[0]?.getAttribute('ref')), `${name} supportedOn`).toBe(true); + expect(policy.getAttribute('class')).toBe('Both'); + expect(policy.getAttribute('key')?.startsWith(POLICY_REGISTRY_ROOT)).toBe(true); + } + }); + + it('keeps the children of a policy in the order the schema requires', () => { + const order = ['parentCategory', 'seeAlso', 'keywords', 'supportedOn', 'enabledValue', 'disabledValue', 'enabledList', 'disabledList', 'elements']; + for (const policy of all(doc, 'policy')) { + const names = Array.from(policy.children).map((child) => order.indexOf(child.localName)); + expect(names, policy.getAttribute('name') ?? '').toEqual([...names].sort((a, b) => a - b)); + expect(names).not.toContain(-1); + } + }); + + it('every key is there, forced and (except the wipe) as a default', () => { + const names = new Set(all(doc, 'policy').map((p) => p.getAttribute('name'))); + for (const key of POLICY_KEYS) { + expect(names.has(key), key).toBe(true); + expect(names.has(`${key}_Recommended`), `${key}_Recommended`).toBe(!POLICY_SCHEMA[key].forcedOnly); + } + expect(names.has('WipeOfflineData_Recommended')).toBe(false); + }); + + it('puts list policies under their own subkey with numbered values', () => { + const lists = POLICY_KEYS.filter((key) => POLICY_SCHEMA[key].type === 'list'); + expect(lists).toEqual(['AllowedJmapServers', 'PluginAllowlist', 'AutoSelectCertificateForUrls']); + for (const key of lists) { + const forced = all(doc, 'policy').find((p) => p.getAttribute('name') === key)!; + expect(forced.getAttribute('key')).toBe(`${POLICY_REGISTRY_ROOT}\\${key}`); + expect(forced.getAttribute('valueName')).toBeNull(); + const list = all(forced, 'list')[0]; + // Empty prefix is what makes Windows name the values 1, 2, 3. + expect(list.getAttribute('valuePrefix')).toBe(''); + const recommended = all(doc, 'policy').find((p) => p.getAttribute('name') === `${key}_Recommended`)!; + expect(recommended.getAttribute('key')).toBe(`${POLICY_REGISTRY_ROOT}\\${POLICY_RECOMMENDED_SUBKEY}\\${key}`); + } + }); + + it('writes booleans as 1 and 0 under the policy\'s value name', () => { + const policy = all(doc, 'policy').find((p) => p.getAttribute('name') === 'AllowStandalone')!; + expect(policy.getAttribute('valueName')).toBe('AllowStandalone'); + expect(all(direct(policy, 'enabledValue')[0], 'decimal')[0].getAttribute('value')).toBe('1'); + expect(all(direct(policy, 'disabledValue')[0], 'decimal')[0].getAttribute('value')).toBe('0'); + }); + + it('writes the enum values the parser accepts', () => { + const policy = all(doc, 'policy').find((p) => p.getAttribute('name') === 'PluginsMode')!; + const values = all(policy, 'string').map((s) => s.textContent); + expect(values).toEqual(['allowed', 'allowlist', 'off']); + for (const value of values) expect(parsePolicyValue('PluginsMode', value).ok).toBe(true); + }); + + it.each(['en', 'de'] as const)('every reference resolves in the %s ADML', (language) => { + const adml = parseXml(generateAdml(language)); + expect(adml.documentElement.localName).toBe('policyDefinitionResources'); + expect(adml.documentElement.namespaceURI).toBe(NS); + const strings = new Set(all(adml, 'string').map((s) => s.getAttribute('id'))); + const presentations = new Map(all(adml, 'presentation').map((p) => [p.getAttribute('id'), p])); + const text = generateAdmx(); + const stringRefs = [...text.matchAll(/\$\(string\.([^)]+)\)/g)].map((m) => m[1]); + expect(stringRefs.length).toBeGreaterThan(40); + for (const id of stringRefs) expect(strings.has(id), id).toBe(true); + for (const policy of all(doc, 'policy')) { + const presentation = policy.getAttribute('presentation'); + const elements = direct(policy, 'elements')[0]; + if (!elements) { + expect(presentation).toBeNull(); + continue; + } + const id = /^\$\(presentation\.(.+)\)$/.exec(presentation ?? '')?.[1]; + expect(id, policy.getAttribute('name') ?? '').toBeTruthy(); + const shown = presentations.get(id!); + expect(shown, id).toBeTruthy(); + // Each input of the policy has a control that refers to it by id. + const refs = Array.from(shown!.children).map((child) => child.getAttribute('refId')); + expect(refs).toEqual(Array.from(elements.children).map((child) => child.getAttribute('id'))); + } + }); + + it('the German text is German and every id is the same as in English', () => { + const ids = (language: 'en' | 'de') => all(parseXml(generateAdml(language)), 'string').map((s) => s.getAttribute('id')); + expect(ids('de')).toEqual(ids('en')); + expect(generateAdml('de')).toContain('Zugelassene Mailserver'); + expect(generateAdml('en')).not.toContain('Zugelassene Mailserver'); + }); +}); + +describe('the samples', () => { + it('the policies.json parses, with nothing refused, and the wipe not set', () => { + const parsed = JSON.parse(generatePoliciesJson()) as Record & { Recommended: Record }; + const { Recommended, ...forced } = parsed; + const snapshot = resolvePolicies({ forcedMachine: forced, forcedUser: {}, recommendedMachine: Recommended, recommendedUser: {} }); + for (const key of POLICY_KEYS) { + expect(snapshot[key].invalid, key).toBeUndefined(); + expect(snapshot[key].dropped, key).toBeUndefined(); + } + expect(snapshot.InstanceUrl.value).toBe('https://mail.example.com/'); + expect(snapshot.UpdateChannel.source).toBe('recommended-machine'); + expect(snapshot.WipeOfflineData.value).toBeNull(); + }); + + it('the mobileconfig is a well-formed plist for the app\'s bundle id, with stable ids', () => { + const text = generateMobileconfig(); + const doc = parseXml(text); + expect(doc.documentElement.localName).toBe('plist'); + expect(text).toContain('org.bulwarkmail.desktop'); + expect(text).not.toContain('WipeOfflineData'); + const uuids = [...text.matchAll(/PayloadUUID<\/key>\s*([^<]+)<\/string>/g)].map((m) => m[1]); + expect(uuids).toHaveLength(2); + expect(new Set(uuids).size).toBe(2); + for (const uuid of uuids) expect(uuid).toMatch(/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-8[0-9A-F]{3}-[0-9A-F]{12}$/); + expect(generateMobileconfig()).toBe(text); + const keys: PolicyKey[] = ['InstanceUrl', 'AllowedJmapServers', 'PluginsMode']; + for (const key of keys) expect(text).toContain(`${key}`); + }); +}); diff --git a/lib/desktop/policy-schema.ts b/lib/desktop/policy-schema.ts new file mode 100644 index 000000000..978973d71 --- /dev/null +++ b/lib/desktop/policy-schema.ts @@ -0,0 +1,772 @@ +/** + * The desktop app's OS policies: every key, its type, its text in en and de, and + * how a raw value becomes a usable one. The ADMX and ADML templates, the sample + * .mobileconfig and the sample policies.json are generated from this file + * (scripts/generate-desktop-policies.mjs), so nobody writes those by hand. + * + * No DOM, no Node, no imports: the desktop repo keeps a byte-for-byte copy in + * src/shared/policy-schema.ts, the embedded server reads it, and so do pages. + * + * A policy value can be anything. A registry value may have the wrong type, a + * JSON file may hold an object where a string belongs, and a list may have a + * million entries. So every value goes through `parsePolicyValue`, which has caps + * and scheme checks, and a value it refuses fails closed: see `onInvalid`. + */ + +export const POLICY_SCHEMA_VERSION = 1; + +/** Under HKLM and HKCU. Only this tree is ever read: nothing a standard user can write. */ +export const POLICY_REGISTRY_ROOT = 'Software\\Policies\\Bulwark\\Desktop'; +export const POLICY_RECOMMENDED_SUBKEY = 'Recommended'; +export const POLICY_BUNDLE_ID = 'org.bulwarkmail.desktop'; +export const POLICY_LINUX_FILE = '/etc/bulwark/policies.json'; + +export const POLICY_MAX_STRING = 2048; +export const POLICY_MAX_NONCE = 128; +export const POLICY_MAX_LIST_ITEMS = 500; +export const POLICY_MAX_CERTIFICATE_RULE = 4096; +export const POLICY_MAX_FILE_BYTES = 1024 * 1024; + +export type PolicyText = { en: string; de: string }; + +export type PolicyCategory = 'connection' | 'updates' | 'plugins' | 'security' | 'privacy' | 'signIn'; + +export const POLICY_CATEGORIES: Record = { + connection: { en: 'Connection', de: 'Verbindung' }, + updates: { en: 'Updates', de: 'Updates' }, + plugins: { en: 'Plugins', de: 'Plugins' }, + security: { en: 'Offline mail and app lock', de: 'Offline-E-Mails und App-Sperre' }, + privacy: { en: 'Privacy', de: 'Datenschutz' }, + signIn: { en: 'Sign-in and certificates', de: 'Anmeldung und Zertifikate' }, +}; + +export interface CertificateName { + CN?: string; + L?: string; + O?: string; + OU?: string; +} + +/** One entry of AutoSelectCertificateForUrls, in Chrome's format. */ +export interface CertificateRule { + /** A lowercase host, optionally with a port. */ + host: string; + /** Also matches subdomains: `[*.]example.com`. */ + subdomains: boolean; + issuer?: CertificateName; + subject?: CertificateName; +} + +export interface PolicyValues { + InstanceUrl: string | null; + AllowStandalone: boolean; + AllowedJmapServers: string[] | null; + AllowInsecureInstance: boolean; + UpdateChannel: 'stable' | 'beta'; + AutoUpdate: boolean; + PluginsMode: 'allowed' | 'allowlist' | 'off'; + PluginAllowlist: string[]; + OfflineEnabled: boolean; + AppLockRequired: boolean; + AppLockTimeoutMinutes: number | null; + TelemetryAllowed: boolean; + CrashReportsAllowed: boolean; + QuickComposeShortcut: string | null; + AuthServerAllowlist: string | null; + AuthNegotiateDelegateAllowlist: string | null; + AutoSelectCertificateForUrls: CertificateRule[]; + WipeOfflineData: string | null; +} + +export type PolicyKey = keyof PolicyValues; + +interface PolicyBase { + category: PolicyCategory; + title: PolicyText; + description: PolicyText; + /** Only a forced value counts: there's no such thing as a recommended wipe. */ + forcedOnly?: boolean; +} + +export type PolicyDefinition = PolicyBase & + ( + | { + type: 'boolean'; + default: boolean; + /** What a forced value that makes no sense means. The strict side, never the open one. */ + onInvalid: boolean; + /** What an example configuration sets. */ + sample: boolean; + } + | { + type: 'integer'; + default: number | null; + min: number; + max: number; + unit: PolicyText; + sample: number; + } + | { + type: 'enum'; + default: string; + onInvalid: string; + values: readonly { value: string; title: PolicyText }[]; + sample: string; + } + | { + type: 'string'; + format: 'instanceUrl' | 'shortcut' | 'hostList' | 'nonce'; + default: null; + maxLength: number; + label: PolicyText; + sample: string; + } + | { + type: 'list'; + item: 'server' | 'plugin' | 'certificate'; + default: readonly never[] | null; + label: PolicyText; + sample: readonly string[]; + } + ); + +export const POLICY_SCHEMA: Record = { + InstanceUrl: { + category: 'connection', + type: 'string', + format: 'instanceUrl', + default: null, + maxLength: POLICY_MAX_STRING, + sample: 'https://mail.example.com', + label: { en: 'Address of the Bulwark server', de: 'Adresse des Bulwark-Servers' }, + title: { en: 'Bulwark server', de: 'Bulwark-Server' }, + description: { + en: 'The address of your Bulwark server, starting with https://. When forced, the app opens on this server, the choice of server is hidden, and the window stays on it. An invalid address shows an error page that names this policy, and the app does not fall back to anything else. As a default, the app opens on this server until the user picks something else.', + de: 'Die Adresse Ihres Bulwark-Servers, beginnend mit https://. Wenn erzwungen, öffnet sich die App auf diesem Server, die Serverauswahl ist ausgeblendet und das Fenster bleibt auf diesem Server. Eine ungültige Adresse zeigt eine Fehlerseite mit dem Namen dieser Richtlinie, und die App weicht auf nichts anderes aus. Als Standardwert öffnet sich die App auf diesem Server, bis der Benutzer etwas anderes wählt.', + }, + }, + AllowStandalone: { + category: 'connection', + type: 'boolean', + default: true, + onInvalid: false, + sample: false, + title: { en: 'Allow direct connections to mail servers', de: 'Direkte Verbindungen zu Mailservern zulassen' }, + description: { + en: 'When disabled, the app only opens a Bulwark server and nobody can sign in directly to a mail server. Accounts added earlier stay on the disk, but are hidden. If you force a Bulwark server and leave this not configured, direct connections are off too. Enable it to offer both.', + de: 'Wenn deaktiviert, öffnet die App nur einen Bulwark-Server, und niemand kann sich direkt bei einem Mailserver anmelden. Früher hinzugefügte Konten bleiben auf dem Datenträger, sind aber ausgeblendet. Wenn Sie einen Bulwark-Server erzwingen und diese Richtlinie nicht konfigurieren, sind direkte Verbindungen ebenfalls aus. Aktivieren Sie sie, um beides anzubieten.', + }, + }, + AllowedJmapServers: { + category: 'connection', + type: 'list', + item: 'server', + default: null, + sample: ['https://mail.example.com', 'https://*.mail.example.com'], + label: { en: 'Allowed mail servers', de: 'Zugelassene Mailserver' }, + title: { en: 'Allowed mail servers', de: 'Zugelassene Mailserver' }, + description: { + en: 'The mail servers people may sign in to. One per line: an address such as https://mail.example.com, or https://*.example.com for all subdomains. A server without a scheme means https. Plain http must be named with http://. When this list is set, everything else is refused.', + de: 'Die Mailserver, bei denen sich Benutzer anmelden dürfen. Einer pro Zeile: eine Adresse wie https://mail.example.com oder https://*.example.com für alle Subdomains. Ein Server ohne Schema bedeutet https. Unverschlüsseltes http muss mit http:// angegeben werden. Ist diese Liste gesetzt, wird alles andere abgelehnt.', + }, + }, + AllowInsecureInstance: { + category: 'connection', + type: 'boolean', + default: false, + onInvalid: false, + sample: false, + title: { en: 'Allow a Bulwark server on plain http', de: 'Bulwark-Server über unverschlüsseltes http zulassen' }, + description: { + en: 'When enabled, the address in "Bulwark server" may start with http://. Passwords and mail then travel unencrypted, and the browser features that need https, such as offline mail and S/MIME, do not work. Leave this off unless the server is on a network you trust.', + de: 'Wenn aktiviert, darf die Adresse unter „Bulwark-Server“ mit http:// beginnen. Passwörter und E-Mails gehen dann unverschlüsselt über das Netzwerk, und Funktionen, die https brauchen, wie Offline-E-Mails und S/MIME, funktionieren nicht. Lassen Sie dies aus, sofern der Server nicht in einem Netzwerk steht, dem Sie vertrauen.', + }, + }, + UpdateChannel: { + category: 'updates', + type: 'enum', + default: 'stable', + onInvalid: 'stable', + sample: 'stable', + values: [ + { value: 'stable', title: { en: 'Stable', de: 'Stabil' } }, + { value: 'beta', title: { en: 'Beta', de: 'Beta' } }, + ], + title: { en: 'Update channel', de: 'Update-Kanal' }, + description: { + en: 'Which releases the app updates to.', + de: 'Auf welche Versionen die App aktualisiert wird.', + }, + }, + AutoUpdate: { + category: 'updates', + type: 'boolean', + default: true, + onInvalid: false, + sample: false, + title: { en: 'Update automatically', de: 'Automatisch aktualisieren' }, + description: { + en: 'When disabled, the app does not look for updates or download them. Use this when SCCM, Intune or similar tools deliver updates.', + de: 'Wenn deaktiviert, sucht die App nicht nach Updates und lädt keine herunter. Verwenden Sie dies, wenn SCCM, Intune oder ähnliche Werkzeuge die Updates liefern.', + }, + }, + PluginsMode: { + category: 'plugins', + type: 'enum', + default: 'allowed', + onInvalid: 'off', + sample: 'allowlist', + values: [ + { value: 'allowed', title: { en: 'All plugins are allowed', de: 'Alle Plugins sind erlaubt' } }, + { value: 'allowlist', title: { en: 'Only plugins on the list', de: 'Nur Plugins auf der Liste' } }, + { value: 'off', title: { en: 'No plugins', de: 'Keine Plugins' } }, + ], + title: { en: 'Which plugins may run', de: 'Welche Plugins laufen dürfen' }, + description: { + en: 'Whether plugins can be installed and run. A plugin that is refused keeps its data and settings: it comes back as it was if the policy allows it again.', + de: 'Ob Plugins installiert und ausgeführt werden dürfen. Ein abgelehntes Plugin behält seine Daten und Einstellungen: Es kommt unverändert zurück, wenn die Richtlinie es wieder erlaubt.', + }, + }, + PluginAllowlist: { + category: 'plugins', + type: 'list', + item: 'plugin', + default: [], + sample: ['example-plugin'], + label: { en: 'Plugin ids', de: 'Plugin-IDs' }, + title: { en: 'Allowed plugins', de: 'Erlaubte Plugins' }, + description: { + en: 'The ids of the plugins that may run when "Which plugins may run" is set to the list. One per line.', + de: 'Die IDs der Plugins, die laufen dürfen, wenn „Welche Plugins laufen dürfen“ auf die Liste gesetzt ist. Eine pro Zeile.', + }, + }, + OfflineEnabled: { + category: 'security', + type: 'boolean', + default: true, + onInvalid: false, + sample: true, + title: { en: 'Keep mail on the device', de: 'E-Mails auf dem Gerät behalten' }, + description: { + en: 'Whether accounts keep an encrypted copy of mail on this device for offline use. When forced off, copies already on the device are removed.', + de: 'Ob Konten eine verschlüsselte Kopie der E-Mails auf diesem Gerät für die Offline-Nutzung behalten. Wenn erzwungen aus, werden bereits vorhandene Kopien entfernt.', + }, + }, + AppLockRequired: { + category: 'security', + type: 'boolean', + default: false, + onInvalid: true, + sample: true, + title: { en: 'Require an app lock', de: 'App-Sperre verlangen' }, + description: { + en: 'When enabled, people must set up the app lock and cannot turn it off.', + de: 'Wenn aktiviert, müssen Benutzer die App-Sperre einrichten und können sie nicht ausschalten.', + }, + }, + AppLockTimeoutMinutes: { + category: 'security', + type: 'integer', + default: null, + min: 1, + max: 1440, + unit: { en: 'Minutes', de: 'Minuten' }, + sample: 10, + title: { en: 'Lock the app after', de: 'App sperren nach' }, + description: { + en: 'How many minutes without use the app stays unlocked. From 1 to 1440.', + de: 'Wie viele Minuten ohne Nutzung die App entsperrt bleibt. Von 1 bis 1440.', + }, + }, + TelemetryAllowed: { + category: 'privacy', + type: 'boolean', + default: true, + onInvalid: false, + sample: false, + title: { en: 'Allow usage statistics', de: 'Nutzungsstatistiken zulassen' }, + description: { + en: 'When disabled, the app sends no usage statistics and does not ask. When enabled or not configured, it still asks the user first.', + de: 'Wenn deaktiviert, sendet die App keine Nutzungsstatistiken und fragt nicht danach. Wenn aktiviert oder nicht konfiguriert, fragt sie den Benutzer trotzdem zuerst.', + }, + }, + CrashReportsAllowed: { + category: 'privacy', + type: 'boolean', + default: true, + onInvalid: false, + sample: false, + title: { en: 'Allow crash reports', de: 'Absturzberichte zulassen' }, + description: { + en: 'When disabled, the app sends no crash reports and does not ask. When enabled or not configured, it still asks the user first.', + de: 'Wenn deaktiviert, sendet die App keine Absturzberichte und fragt nicht danach. Wenn aktiviert oder nicht konfiguriert, fragt sie den Benutzer trotzdem zuerst.', + }, + }, + QuickComposeShortcut: { + category: 'security', + type: 'string', + format: 'shortcut', + default: null, + maxLength: 64, + sample: 'CommandOrControl+Alt+N', + label: { en: 'Shortcut', de: 'Tastenkombination' }, + title: { en: 'Global shortcut for a new message', de: 'Globale Tastenkombination für neue Nachricht' }, + description: { + en: 'The key combination that opens a new message from anywhere, such as CommandOrControl+Alt+N. Enter off to turn the shortcut off.', + de: 'Die Tastenkombination, die von überall aus eine neue Nachricht öffnet, etwa CommandOrControl+Alt+N. Geben Sie off ein, um sie auszuschalten.', + }, + }, + AuthServerAllowlist: { + category: 'signIn', + type: 'string', + format: 'hostList', + default: null, + maxLength: POLICY_MAX_STRING, + sample: '*.example.com', + label: { en: 'Servers', de: 'Server' }, + title: { en: 'Servers for integrated sign-in', de: 'Server für die integrierte Anmeldung' }, + description: { + en: 'The servers that may use the Windows sign-in (Kerberos, NTLM), separated by commas, such as *.example.com. Takes effect when the app restarts.', + de: 'Die Server, die die Windows-Anmeldung (Kerberos, NTLM) verwenden dürfen, durch Kommas getrennt, etwa *.example.com. Wird beim Neustart der App wirksam.', + }, + }, + AuthNegotiateDelegateAllowlist: { + category: 'signIn', + type: 'string', + format: 'hostList', + default: null, + maxLength: POLICY_MAX_STRING, + sample: '*.example.com', + label: { en: 'Servers', de: 'Server' }, + title: { en: 'Servers for delegated sign-in', de: 'Server für die delegierte Anmeldung' }, + description: { + en: 'The servers the app may delegate Kerberos credentials to, separated by commas. Takes effect when the app restarts.', + de: 'Die Server, an die die App Kerberos-Anmeldedaten delegieren darf, durch Kommas getrennt. Wird beim Neustart der App wirksam.', + }, + }, + AutoSelectCertificateForUrls: { + category: 'signIn', + type: 'list', + item: 'certificate', + default: [], + sample: ['{"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}'], + label: { en: 'Rules', de: 'Regeln' }, + title: { en: 'Choose client certificates automatically', de: 'Clientzertifikate automatisch wählen' }, + description: { + en: 'One JSON rule per line, in the format Chrome uses: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. The app then picks the matching certificate for that site without asking.', + de: 'Eine JSON-Regel pro Zeile, im Format von Chrome: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. Die App wählt dann für diese Website das passende Zertifikat ohne Rückfrage.', + }, + }, + WipeOfflineData: { + category: 'security', + type: 'string', + format: 'nonce', + default: null, + forcedOnly: true, + maxLength: POLICY_MAX_NONCE, + sample: '2026-11-03-1', + label: { en: 'Wipe code', de: 'Löschcode' }, + title: { en: 'Delete mail kept on the device', de: 'Auf dem Gerät behaltene E-Mails löschen' }, + description: { + en: 'Set this to a new value, such as today\'s date, to delete all mail the app keeps on the device. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again.', + de: 'Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alle E-Mails zu löschen, die die App auf dem Gerät behält. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen.', + }, + }, +}; + +export const POLICY_KEYS = Object.keys(POLICY_SCHEMA) as PolicyKey[]; + +export type PolicySource = 'forced-machine' | 'forced-user' | 'recommended-machine' | 'recommended-user' | 'default'; + +export interface PolicyEntry { + value: PolicyValues[K]; + source: PolicySource; + /** Forced: the user can't change it, and the setting shows "Set by your organisation". */ + locked: boolean; + /** A forced value we refused: `value` is then the strict one. Names the reason. */ + invalid?: string; + /** Part of a list we dropped, while the rest stands. */ + dropped?: number; + /** What was set, for an error page. Capped. */ + raw?: string; +} + +export type PolicySnapshot = { [K in PolicyKey]: PolicyEntry }; + +/** One level of the registry, the profile or the file: raw values, untrusted. */ +export type PolicyLayer = Partial>; + +export interface PolicyLayers { + forcedMachine: PolicyLayer; + forcedUser: PolicyLayer; + recommendedMachine: PolicyLayer; + recommendedUser: PolicyLayer; +} + +export const EMPTY_POLICY_LAYERS: PolicyLayers = { forcedMachine: {}, forcedUser: {}, recommendedMachine: {}, recommendedUser: {} }; + +type Parsed = { ok: true; value: T; dropped?: number } | { ok: false; reason: string }; + +// eslint-disable-next-line no-control-regex +const CONTROL = /[\u0000-\u001f\u007f]/; + +function fail(reason: string): Parsed { + return { ok: false, reason }; +} + +/** + * A Bulwark server's address, normalised to `origin + path + /`. Only https, or http + * when the policy says so. No credentials, no query, no fragment. + */ +export function parseInstanceUrl(raw: unknown, allowInsecure: boolean): Parsed { + if (typeof raw !== 'string') return fail('not_text'); + const text = raw.trim(); + if (!text) return fail('empty'); + if (text.length > POLICY_MAX_STRING) return fail('too_long'); + if (CONTROL.test(text)) return fail('invalid_url'); + let url: URL; + try { + url = new URL(text); + } catch { + return fail('invalid_url'); + } + if (url.protocol === 'http:') { + if (!allowInsecure) return fail('insecure'); + } else if (url.protocol !== 'https:') { + return fail('scheme'); + } + if (!url.hostname || url.username || url.password || url.search || url.hash) return fail('invalid_url'); + return { ok: true, value: `${url.origin}${url.pathname.replace(/\/+$/, '')}/` }; +} + +/** + * One entry of AllowedJmapServers, normalised: `https://mail.example.com`, + * `http://10.0.0.5:8080`, or `https://*.example.com`. + */ +export function parseServerPattern(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + let text = raw.trim(); + if (!text || text.length > POLICY_MAX_STRING || CONTROL.test(text) || /\s/.test(text)) return null; + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) text = `https://${text}`; + const wildcard = /^([a-z]+:\/\/)\*\.(.+)$/i.exec(text); + const candidate = wildcard ? `${wildcard[1]}${wildcard[2]}` : text; + if (candidate.includes('*')) return null; + let url: URL; + try { + url = new URL(candidate); + } catch { + return null; + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') return null; + if (!url.hostname || url.username || url.password || url.search || url.hash) return null; + if (url.pathname !== '/' && url.pathname !== '') return null; + // A wildcard over a bare name would match the whole of a top-level domain. + if (wildcard && !url.hostname.includes('.')) return null; + return wildcard ? `${url.protocol}//*.${url.host}` : url.origin; +} + +/** Whether a mail server's address is on the list. Null means no list, so everything is. */ +export function jmapServerAllowed(patterns: readonly string[] | null | undefined, serverUrl: unknown): boolean { + if (patterns === null || patterns === undefined) return true; + if (typeof serverUrl !== 'string' || CONTROL.test(serverUrl)) return false; + let url: URL; + try { + url = new URL(serverUrl.trim()); + } catch { + return false; + } + if (url.username || url.password) return false; + for (const pattern of patterns) { + const wildcard = /^([a-z]+:)\/\/\*\.(.+)$/.exec(pattern); + if (!wildcard) { + if (url.origin === pattern) return true; + continue; + } + const [, scheme, host] = wildcard; + if (url.protocol === scheme && url.host.endsWith(`.${host}`)) return true; + } + return false; +} + +const PLUGIN_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/; + +export function parsePluginId(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const id = raw.trim().toLowerCase(); + return PLUGIN_ID.test(id) ? id : null; +} + +export type PluginsMode = PolicyValues['PluginsMode']; + +/** The one question every plugin gate asks, in the server and in the page. */ +export function pluginAllowedByPolicy(mode: PluginsMode, allowlist: readonly string[], id: unknown): boolean { + if (mode === 'allowed') return true; + if (mode !== 'allowlist' || typeof id !== 'string') return false; + return allowlist.includes(id.trim().toLowerCase()); +} + +const SHORTCUT = /^(?:(?:Command|Cmd|Control|Ctrl|CommandOrControl|CmdOrCtrl|Alt|Option|AltGr|Shift|Super|Meta)\+){1,4}(?:[A-Z0-9]|F[1-9]|F1[0-9]|F2[0-4]|Space|Tab|Enter|Backspace|Delete|Insert|Home|End|PageUp|PageDown|Up|Down|Left|Right|Plus|[-=,./;'`[\]\\])$/i; + +function parseShortcut(raw: unknown): Parsed { + if (typeof raw !== 'string') return fail('not_text'); + const text = raw.trim(); + if (text === '' || text.toLowerCase() === 'off') return { ok: true, value: 'off' }; + if (text.length > 64 || !SHORTCUT.test(text)) return fail('invalid_shortcut'); + return { ok: true, value: text }; +} + +const HOST_LIST = /^[A-Za-z0-9*.,:_[\]-]+$/; + +/** What Chromium takes for its auth lists: names and wildcards, commas between them. */ +function parseHostList(raw: unknown): Parsed { + if (typeof raw !== 'string') return fail('not_text'); + const text = raw.replace(/\s*,\s*/g, ',').trim(); + if (!text) return fail('empty'); + if (text.length > POLICY_MAX_STRING) return fail('too_long'); + if (!HOST_LIST.test(text)) return fail('invalid_host_list'); + const hosts = text.split(',').filter(Boolean); + if (hosts.length === 0 || hosts.length > POLICY_MAX_LIST_ITEMS) return fail('invalid_host_list'); + // `*` alone would hand Windows credentials to any site that asks. + if (hosts.some((host) => host === '*' || host === '*.')) return fail('invalid_host_list'); + return { ok: true, value: hosts.join(',') }; +} + +function parseNonce(raw: unknown): Parsed { + const text = typeof raw === 'number' && Number.isFinite(raw) ? String(raw) : raw; + if (typeof text !== 'string') return fail('not_text'); + const value = text.trim(); + if (!value) return fail('empty'); + if (value.length > POLICY_MAX_NONCE || CONTROL.test(value)) return fail('too_long'); + return { ok: true, value }; +} + +function certificateName(raw: unknown): CertificateName | undefined | null { + if (raw === undefined) return undefined; + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null; + const name: CertificateName = {}; + for (const part of ['CN', 'L', 'O', 'OU'] as const) { + const value = (raw as Record)[part]; + if (value === undefined) continue; + if (typeof value !== 'string' || value.length > 256 || CONTROL.test(value)) return null; + name[part] = value; + } + return name; +} + +/** + * Chrome's format: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"x"}}}. + * A rule must name a site: `*` would hand the certificate to any site that asks, + * without the user seeing it. + */ +export function parseCertificateRule(raw: unknown): CertificateRule | null { + if (typeof raw !== 'string' || raw.length > POLICY_MAX_CERTIFICATE_RULE) return null; + let rule: unknown; + try { + rule = JSON.parse(raw); + } catch { + return null; + } + if (typeof rule !== 'object' || rule === null || Array.isArray(rule)) return null; + const { pattern, filter } = rule as { pattern?: unknown; filter?: unknown }; + if (typeof pattern !== 'string' || pattern.length > POLICY_MAX_STRING || CONTROL.test(pattern) || /\s/.test(pattern)) return null; + const match = /^(?:https:\/\/)?(\[\*\.\]|\*\.)?([^/*[\]]+(?::\d+)?)\/?$/i.exec(pattern); + if (!match) return null; + let host: string; + try { + host = new URL(`https://${match[2]}`).host; + } catch { + return null; + } + if (!host) return null; + if (filter !== undefined && (typeof filter !== 'object' || filter === null || Array.isArray(filter))) return null; + const { ISSUER, SUBJECT } = (filter ?? {}) as { ISSUER?: unknown; SUBJECT?: unknown }; + const issuer = certificateName(ISSUER); + const subject = certificateName(SUBJECT); + if (issuer === null || subject === null) return null; + return { host, subdomains: Boolean(match[1]), ...(issuer ? { issuer } : {}), ...(subject ? { subject } : {}) }; +} + +/** Whether a rule covers this https address. */ +export function certificateRuleMatchesUrl(rule: CertificateRule, url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (parsed.protocol !== 'https:') return false; + return parsed.host === rule.host || (rule.subdomains && parsed.host.endsWith(`.${rule.host}`)); +} + +/** What's needed from a certificate: Electron's `Certificate` fits. */ +export interface CertificateFacts { + issuer?: { commonName?: string; locality?: string; organizations?: string[]; organizationUnits?: string[] }; + subject?: { commonName?: string; locality?: string; organizations?: string[]; organizationUnits?: string[] }; +} + +function nameMatches(wanted: CertificateName | undefined, actual: CertificateFacts['issuer']): boolean { + if (!wanted) return true; + if (wanted.CN !== undefined && actual?.commonName !== wanted.CN) return false; + if (wanted.L !== undefined && actual?.locality !== wanted.L) return false; + if (wanted.O !== undefined && !actual?.organizations?.includes(wanted.O)) return false; + if (wanted.OU !== undefined && !actual?.organizationUnits?.includes(wanted.OU)) return false; + return true; +} + +export function certificateMatchesRule(rule: CertificateRule, certificate: CertificateFacts): boolean { + return nameMatches(rule.issuer, certificate.issuer) && nameMatches(rule.subject, certificate.subject); +} + +function parseBoolean(raw: unknown): Parsed { + if (raw === true || raw === 1 || raw === '1') return { ok: true, value: true }; + if (raw === false || raw === 0 || raw === '0') return { ok: true, value: false }; + return fail('not_a_switch'); +} + +function parseInteger(raw: unknown, min: number, max: number): Parsed { + const value = typeof raw === 'string' && /^\d{1,10}$/.test(raw.trim()) ? Number(raw.trim()) : raw; + if (typeof value !== 'number' || !Number.isInteger(value)) return fail('not_a_number'); + if (value < min || value > max) return fail('out_of_range'); + return { ok: true, value }; +} + +function parseList(raw: unknown, item: (entry: unknown) => T | null): Parsed { + if (!Array.isArray(raw)) return fail('not_a_list'); + if (raw.length > POLICY_MAX_LIST_ITEMS) return fail('too_many'); + const out: T[] = []; + let dropped = 0; + for (const entry of raw) { + const parsed = item(entry); + if (parsed === null) dropped += 1; + else out.push(parsed); + } + return dropped ? { ok: true, value: out, dropped } : { ok: true, value: out }; +} + +/** + * A raw value into what the app uses. The InstanceUrl needs to know whether http is + * allowed: pass the resolved AllowInsecureInstance. + */ +export function parsePolicyValue(key: PolicyKey, raw: unknown, allowInsecure = false): Parsed { + const definition = POLICY_SCHEMA[key]; + switch (definition.type) { + case 'boolean': + return parseBoolean(raw); + case 'integer': + return parseInteger(raw, definition.min, definition.max); + case 'enum': { + const allowed = typeof raw === 'string' && definition.values.some((option) => option.value === raw.trim()); + return allowed ? ({ ok: true, value: (raw as string).trim() } as Parsed) : fail('unknown_value'); + } + case 'string': + switch (definition.format) { + case 'instanceUrl': + return parseInstanceUrl(raw, allowInsecure); + case 'shortcut': + return parseShortcut(raw); + case 'hostList': + return parseHostList(raw); + case 'nonce': + return parseNonce(raw); + } + break; + case 'list': + switch (definition.item) { + case 'server': + return parseList(raw, parseServerPattern); + case 'plugin': + return parseList(raw, parsePluginId); + case 'certificate': + return parseList(raw, parseCertificateRule); + } + } + return fail('unknown_policy'); +} + +/** The strict side, for a forced value we can't read. Null where that's "not set". */ +function failClosedValue(key: PolicyKey): PolicyValues[PolicyKey] { + const definition = POLICY_SCHEMA[key]; + if (definition.type === 'boolean' || definition.type === 'enum') return definition.onInvalid as PolicyValues[PolicyKey]; + // A list nobody can read allows nothing. + if (definition.type === 'list') return [] as PolicyValues[PolicyKey]; + // A shortcut nobody can read doesn't get the built-in one instead. + if (definition.type === 'string' && definition.format === 'shortcut') return 'off' as PolicyValues[PolicyKey]; + return definition.default as PolicyValues[PolicyKey]; +} + +function describeRaw(raw: unknown): string { + let text: string; + try { + text = typeof raw === 'string' ? raw : (JSON.stringify(raw) ?? String(raw)); + } catch { + text = String(raw); + } + return text.length > 200 ? `${text.slice(0, 200)}…` : text; +} + +const ORDER: { layer: keyof PolicyLayers; source: PolicySource; locked: boolean }[] = [ + { layer: 'forcedMachine', source: 'forced-machine', locked: true }, + { layer: 'forcedUser', source: 'forced-user', locked: true }, + { layer: 'recommendedMachine', source: 'recommended-machine', locked: false }, + { layer: 'recommendedUser', source: 'recommended-user', locked: false }, +]; + +function resolveEntry(key: K, layers: PolicyLayers, allowInsecure: boolean): PolicyEntry { + const definition = POLICY_SCHEMA[key]; + for (const { layer, source, locked } of ORDER) { + if (!locked && definition.forcedOnly) continue; + if (!Object.prototype.hasOwnProperty.call(layers[layer], key)) continue; + const raw = layers[layer][key]; + if (raw === undefined) continue; + const parsed = parsePolicyValue(key, raw, allowInsecure); + if (parsed.ok) { + const entry = { value: parsed.value as PolicyValues[K], source, locked } as PolicyEntry; + if (parsed.dropped) entry.dropped = parsed.dropped; + return entry; + } + // A recommendation we can't read is no recommendation. A forced value we can't + // read still means the admin wanted something: it fails closed, and a weaker + // layer never gets to speak instead. + if (!locked) continue; + // An empty nonce is someone clearing it, not a broken value. + if (definition.type === 'string' && definition.format === 'nonce' && parsed.reason === 'empty') { + return { value: null as PolicyValues[K], source: 'default', locked: false } as PolicyEntry; + } + return { value: failClosedValue(key) as PolicyValues[K], source, locked, invalid: parsed.reason, raw: describeRaw(raw) } as PolicyEntry; + } + return { value: definition.default as PolicyValues[K], source: 'default', locked: false } as PolicyEntry; +} + +/** Forced machine > forced user > (the user's choice) > recommended machine > recommended user > default. */ +export function resolvePolicies(layers: PolicyLayers): PolicySnapshot { + const insecure = resolveEntry('AllowInsecureInstance', layers, false).value; + const snapshot = {} as Record; + for (const key of POLICY_KEYS) snapshot[key] = resolveEntry(key, layers, insecure) as PolicyEntry; + return snapshot as PolicySnapshot; +} + +/** + * The place of the user's own choice in the order: under anything forced, over any + * recommendation. `undefined` and `null` mean they haven't chosen. + */ +export function effectivePolicyValue(entry: { value: T; locked: boolean }, choice: T | null | undefined): T { + if (entry.locked) return entry.value; + return choice === undefined || choice === null ? entry.value : choice; +} + +export const DEFAULT_POLICY_SNAPSHOT: PolicySnapshot = resolvePolicies(EMPTY_POLICY_LAYERS); + +/** The part the embedded server enforces. Sent to it by message. */ +export interface ServerPolicy { + pluginsMode: PluginsMode; + pluginAllowlist: string[]; + allowedJmapServers: string[] | null; +} + +export function serverPolicyOf(snapshot: PolicySnapshot): ServerPolicy { + return { + pluginsMode: snapshot.PluginsMode.value, + pluginAllowlist: snapshot.PluginAllowlist.value, + allowedJmapServers: snapshot.AllowedJmapServers.value, + }; +} diff --git a/lib/desktop/policy-templates.ts b/lib/desktop/policy-templates.ts new file mode 100644 index 000000000..bd67c4dd4 --- /dev/null +++ b/lib/desktop/policy-templates.ts @@ -0,0 +1,305 @@ +/** + * Turns the policy schema into the files admins deploy: ADMX and ADML for Group + * Policy and Intune, a sample .mobileconfig for macOS, a sample policies.json for + * Linux. `scripts/generate-desktop-policies.mjs` writes them to desktop-policies/; + * a test fails when what's committed differs from what this produces. + */ + +import { + POLICY_BUNDLE_ID, + POLICY_CATEGORIES, + POLICY_KEYS, + POLICY_LINUX_FILE, + POLICY_RECOMMENDED_SUBKEY, + POLICY_REGISTRY_ROOT, + POLICY_SCHEMA, + type PolicyCategory, + type PolicyDefinition, + type PolicyKey, + type PolicyText, +} from './policy-schema'; + +type Language = keyof PolicyText; + +const NAMESPACE = 'http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions'; +const SUPPORTED = 'SUPPORTED_Bulwark_Desktop'; +const ROOT_CATEGORY = 'Bulwark'; +const FORCED_CATEGORY = 'Bulwark_Desktop'; +const RECOMMENDED_CATEGORY = 'Bulwark_Desktop_Recommended'; + +const CATEGORY_IDS = Object.keys(POLICY_CATEGORIES) as PolicyCategory[]; + +function xml(text: string): string { + return text.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] as string); +} + +function categoryId(category: PolicyCategory): string { + return `Bulwark_Desktop_${category}`; +} + +/** A policy appears twice in the tree: forced, and as a default the user can change. */ +function policyVariants(key: PolicyKey): { recommended: boolean; id: string }[] { + return POLICY_SCHEMA[key].forcedOnly + ? [{ recommended: false, id: key }] + : [{ recommended: false, id: key }, { recommended: true, id: `${key}_Recommended` }]; +} + +function policyElement(key: PolicyKey, recommended: boolean, id: string): string { + const definition = POLICY_SCHEMA[key]; + const root = recommended ? `${POLICY_REGISTRY_ROOT}\\${POLICY_RECOMMENDED_SUBKEY}` : POLICY_REGISTRY_ROOT; + const parent = recommended ? RECOMMENDED_CATEGORY : categoryId(definition.category); + const common = `class="Both" displayName="$(string.${key})" explainText="$(string.${key}_Explain)"`; + const head = ` \n `; + switch (definition.type) { + case 'boolean': + return [ + ` `, + head, + ' ', + ' ', + ' ', + ].join('\n'); + case 'integer': + return [ + ` `, + head, + ' ', + ` `, + ' ', + ' ', + ].join('\n'); + case 'enum': + return [ + ` `, + head, + ' ', + ` `, + ...definition.values.map( + (option) => ` ${xml(option.value)}`, + ), + ' ', + ' ', + ' ', + ].join('\n'); + case 'string': + return [ + ` `, + head, + ' ', + ` `, + ' ', + ' ', + ].join('\n'); + case 'list': + // valuePrefix="" gives values named 1, 2, 3 in a subkey of the policy's name. + return [ + ` `, + head, + ' ', + ` `, + ' ', + ' ', + ].join('\n'); + } +} + +export function generateAdmx(): string { + const categories = [ + ` `, + ` `, + ` `, + ...CATEGORY_IDS.map( + (category) => ` `, + ), + ]; + const policies = POLICY_KEYS.flatMap((key) => policyVariants(key).map(({ recommended, id }) => policyElement(key, recommended, id))); + return [ + '', + '', + ``, + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ` `, + ' ', + ' ', + ' ', + ...categories, + ' ', + ' ', + ...policies, + ' ', + '', + '', + ].join('\n'); +} + +const INTRO: PolicyText = { + en: 'Settings for the Bulwark desktop app.', + de: 'Einstellungen für die Bulwark-Desktop-App.', +}; +const SUPPORTED_TEXT: PolicyText = { en: 'Bulwark Desktop 1.0 and later', de: 'Bulwark Desktop 1.0 und höher' }; +const RECOMMENDED_TEXT: PolicyText = { en: 'Bulwark Desktop: defaults users can change', de: 'Bulwark Desktop: Standardwerte, die Benutzer ändern können' }; + +function presentationFor(key: PolicyKey, id: string, language: Language): string | null { + const definition: PolicyDefinition = POLICY_SCHEMA[key]; + const refId = `${id}_Value`; + switch (definition.type) { + case 'boolean': + return null; + case 'integer': + return ` ${xml(definition.unit[language])}`; + case 'enum': + return ` ${xml(definition.title[language])}`; + case 'string': + return ` `; + case 'list': + return ` ${xml(definition.label[language])}`; + } +} + +export function generateAdml(language: Language): string { + const strings: [string, string][] = [ + ['Category_Bulwark', 'Bulwark'], + ['Category_Desktop', 'Bulwark Desktop'], + ['Category_Recommended', RECOMMENDED_TEXT[language]], + [SUPPORTED, SUPPORTED_TEXT[language]], + ...CATEGORY_IDS.map((category): [string, string] => [`Category_${category}`, POLICY_CATEGORIES[category][language]]), + ]; + for (const key of POLICY_KEYS) { + const definition = POLICY_SCHEMA[key]; + strings.push([key, definition.title[language]], [`${key}_Explain`, definition.description[language]]); + if (definition.type === 'enum') { + for (const option of definition.values) strings.push([`${key}_${option.value}`, option.title[language]]); + } + } + const presentations = POLICY_KEYS.flatMap((key) => + policyVariants(key) + .map(({ id }) => presentationFor(key, id, language)) + .filter((line): line is string => line !== null), + ); + return [ + '', + '', + ``, + ' Bulwark Desktop', + ` ${xml(INTRO[language])}`, + ' ', + ' ', + ...strings.map(([id, text]) => ` ${xml(text)}`), + ' ', + ' ', + ...presentations, + ' ', + ' ', + '', + '', + ].join('\n'); +} + +/** A stable UUID, so generating twice gives the same file. Not a secret, only an identifier. */ +function stableUuid(name: string): string { + let a = 0x811c9dc5; + let b = 0x01000193; + for (const char of `bulwark-policy:${name}`) { + const code = char.charCodeAt(0); + a = Math.imul(a ^ code, 16777619) >>> 0; + b = Math.imul(b + code, 2166136261) >>> 0; + } + const hex = (value: number) => value.toString(16).padStart(8, '0'); + const all = `${hex(a)}${hex(b)}${hex(Math.imul(a, b) >>> 0)}${hex((a ^ b) >>> 0)}`; + return `${all.slice(0, 8)}-${all.slice(8, 12)}-4${all.slice(13, 16)}-8${all.slice(17, 20)}-${all.slice(20, 32)}`.toUpperCase(); +} + +function plistValue(value: unknown, indent: string): string { + if (typeof value === 'boolean') return `${indent}<${value}/>`; + if (typeof value === 'number') return `${indent}${value}`; + if (typeof value === 'string') return `${indent}${xml(value)}`; + if (Array.isArray(value)) { + return [`${indent}`, ...value.map((item) => plistValue(item, `${indent} `)), `${indent}`].join('\n'); + } + const entries = Object.entries(value as Record); + return [ + `${indent}`, + ...entries.flatMap(([key, item]) => [`${indent} ${xml(key)}`, plistValue(item, `${indent} `)]), + `${indent}`, + ].join('\n'); +} + +/** What the samples set. A sample that would delete mail when deployed as it is doesn't set the nonce. */ +function sampleValues(): Record { + const sample: Record = {}; + for (const key of POLICY_KEYS) { + if (key === 'WipeOfflineData') continue; + const definition = POLICY_SCHEMA[key]; + sample[key] = definition.sample; + } + return sample; +} + +export function generateMobileconfig(): string { + const { InstanceUrl, UpdateChannel, ...forced } = sampleValues(); + const payload = { + PayloadType: POLICY_BUNDLE_ID, + PayloadVersion: 1, + PayloadIdentifier: `${POLICY_BUNDLE_ID}.policy.settings`, + PayloadUUID: stableUuid('settings'), + PayloadEnabled: true, + PayloadDisplayName: 'Bulwark settings', + InstanceUrl, + ...forced, + // Not forced: a default the user can change. + Recommended: { UpdateChannel }, + }; + const profile = { + PayloadContent: [payload], + PayloadType: 'Configuration', + PayloadVersion: 1, + PayloadIdentifier: `${POLICY_BUNDLE_ID}.policy`, + PayloadUUID: stableUuid('profile'), + PayloadDisplayName: 'Bulwark Desktop', + PayloadDescription: 'A sample profile. Remove what you do not want to set, and change the rest.', + PayloadScope: 'System', + }; + return [ + '', + '', + '', + '', + plistValue(profile, ''), + '', + '', + ].join('\n'); +} + +export function generatePoliciesJson(): string { + const { UpdateChannel, ...forced } = sampleValues(); + return `${JSON.stringify({ ...forced, Recommended: { UpdateChannel } }, null, 2)}\n`; +} + +/** Relative path under desktop-policies/ to its content. */ +export function generatePolicyFiles(): Record { + return { + 'Bulwark.admx': generateAdmx(), + 'en-US/Bulwark.adml': generateAdml('en'), + 'de-DE/Bulwark.adml': generateAdml('de'), + [`${POLICY_BUNDLE_ID}.mobileconfig`]: generateMobileconfig(), + 'policies.json': generatePoliciesJson(), + 'README.txt': [ + 'Generated by scripts/generate-desktop-policies.mjs. Do not edit these files.', + '', + 'Windows: copy Bulwark.admx to %SystemRoot%\\PolicyDefinitions, and the en-US and', + 'de-DE folders next to it (or to the central store in SYSVOL). In Intune, import', + 'Bulwark.admx and en-US\\Bulwark.adml.', + '', + `macOS: ${POLICY_BUNDLE_ID}.mobileconfig is a sample. Change it and deploy it by MDM.`, + '', + `Linux: policies.json is a sample for ${POLICY_LINUX_FILE}. It must be owned by root`, + 'and writable only by root (mode 644). Otherwise the app refuses to start.', + '', + ].join('\n'), + }; +} diff --git a/scripts/generate-desktop-policies.mjs b/scripts/generate-desktop-policies.mjs new file mode 100644 index 000000000..e800d9d47 --- /dev/null +++ b/scripts/generate-desktop-policies.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// Writes the desktop app's policy templates to desktop-policies/, from +// lib/desktop/policy-schema.ts: ADMX, ADML (en, de), a sample .mobileconfig and a +// sample policies.json. lib/desktop/__tests__/policy-templates.test.ts fails when +// what's committed differs from this. +// +// node scripts/generate-desktop-policies.mjs +// node scripts/generate-desktop-policies.mjs --shell +// +// --shell also copies the schema to the desktop repo's src/shared/policy-schema.ts. + +import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { build } from 'esbuild'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const OUT = path.join(ROOT, 'desktop-policies'); + +const temp = mkdtempSync(path.join(tmpdir(), 'bulwark-policies-')); +const bundle = path.join(temp, 'templates.mjs'); +await build({ + entryPoints: [path.join(ROOT, 'lib/desktop/policy-templates.ts')], + outfile: bundle, + bundle: true, + format: 'esm', + platform: 'neutral', + logLevel: 'warning', +}); +const { generatePolicyFiles } = await import(pathToFileURL(bundle).href); +rmSync(temp, { recursive: true, force: true }); + +for (const [name, content] of Object.entries(generatePolicyFiles())) { + const file = path.join(OUT, name); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, content); + console.log(path.relative(ROOT, file)); +} + +const shellIndex = process.argv.indexOf('--shell'); +if (shellIndex !== -1) { + const target = path.join(process.argv[shellIndex + 1], 'src', 'shared', 'policy-schema.ts'); + copyFileSync(path.join(ROOT, 'lib/desktop/policy-schema.ts'), target); + console.log(target); +} From e8696094e44ef15033c0a774d82ab4338921c03f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:05:43 +0100 Subject: [PATCH 17/35] fix: open an account again in the owner tab after another tab cleared it --- .../__tests__/effective-scope-wiring.test.ts | 18 +++++++++++++++++- lib/offline/offline-mode.ts | 10 +++++++++- lib/store/__tests__/host.test.ts | 12 ++++++++++++ lib/store/__tests__/page-side.test.ts | 16 ++++++++++++++++ lib/store/area.ts | 4 +++- lib/store/client/api.ts | 2 ++ lib/store/worker/host.ts | 4 +++- 7 files changed, 62 insertions(+), 4 deletions(-) diff --git a/lib/offline/__tests__/effective-scope-wiring.test.ts b/lib/offline/__tests__/effective-scope-wiring.test.ts index d960f0d58..c454a8570 100644 --- a/lib/offline/__tests__/effective-scope-wiring.test.ts +++ b/lib/offline/__tests__/effective-scope-wiring.test.ts @@ -17,10 +17,11 @@ import { registerOfflineHooks, scopeForAccount, setOfflineNotifier, + startOfflineMode, wipeEverything, type OfflineNotice, } from '../offline-mode'; -import { useOfflineStore, writeEnabled } from '../state'; +import { OFFLINE_HEALTH_KEY, useOfflineStore, writeEnabled, writeHealth } from '../state'; const NOW = Date.now(); const iso = (daysAgo: number) => new Date(NOW - daysAgo * 86_400_000).toISOString(); @@ -155,6 +156,21 @@ describe('clearing an account', () => { }); }); +describe('clearing in another tab', () => { + it('opens the new store here, in case this tab owns it', async () => { + writeEnabled(['acc']); + writeHealth('acc', { opened: true }); + const stop = startOfflineMode(); + const ensureOpen = vi.spyOn(store, 'ensureOpen'); + ensureOpen.mockClear(); + localStorage.setItem(OFFLINE_HEALTH_KEY, JSON.stringify({ acc: { opened: false, pending: 0 } })); + window.dispatchEvent(new StorageEvent('storage', { key: OFFLINE_HEALTH_KEY })); + expect(ensureOpen).toHaveBeenCalledWith('acc'); + expect(useOfflineStore.getState().health.acc.opened).toBe(false); + stop(); + }); +}); + describe('the desktop app', () => { const asDesktop = () => { (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 }; }; diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 9ccda7694..cc6b0c945 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -1066,7 +1066,15 @@ export function startOfflineMode(): () => void { // switched on there has to be opened here if this tab owns the store. const onStorage = (event: StorageEvent) => { if (event.key === OFFLINE_CHOICES_KEY || event.key === null) useOfflineStore.setState({ choices: readStoredChoices() }); - if (event.key === OFFLINE_HEALTH_KEY || event.key === null) useOfflineStore.setState({ health: readStoredHealth() }); + if (event.key === OFFLINE_HEALTH_KEY || event.key === null) { + const before = useOfflineStore.getState().health; + const health = readStoredHealth(); + useOfflineStore.setState({ health }); + // Another tab cleared an account that stays on: if this tab owns the store, it opens the new one. + for (const id of useOfflineStore.getState().enabled) { + if (before[id]?.opened && health[id]?.opened === false) void getStore().ensureOpen(id).catch(() => {}); + } + } if (event.key !== OFFLINE_ACCOUNTS_KEY && event.key !== null) return; const next = readEnabledFrom(event.key === null ? null : event.newValue); const before = useOfflineStore.getState().enabled; diff --git a/lib/store/__tests__/host.test.ts b/lib/store/__tests__/host.test.ts index cff241a70..d4d27d46d 100644 --- a/lib/store/__tests__/host.test.ts +++ b/lib/store/__tests__/host.test.ts @@ -98,6 +98,18 @@ describe('files with no key', () => { }); }); +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(); diff --git a/lib/store/__tests__/page-side.test.ts b/lib/store/__tests__/page-side.test.ts index 045919141..c8efd1642 100644 --- a/lib/store/__tests__/page-side.test.ts +++ b/lib/store/__tests__/page-side.test.ts @@ -250,6 +250,22 @@ 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('stops waiting when the engine couldn\'t start in this browser', async () => { allowEngine(); const store = getStore(); diff --git a/lib/store/area.ts b/lib/store/area.ts index dcb4340d6..e5440d74b 100644 --- a/lib/store/area.ts +++ b/lib/store/area.ts @@ -67,7 +67,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; diff --git a/lib/store/client/api.ts b/lib/store/client/api.ts index d2b1f5532..7d4731871 100644 --- a/lib/store/client/api.ts +++ b/lib/store/client/api.ts @@ -75,6 +75,8 @@ 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: () => { diff --git a/lib/store/worker/host.ts b/lib/store/worker/host.ts index 0620d05b3..14002de2b 100644 --- a/lib/store/worker/host.ts +++ b/lib/store/worker/host.ts @@ -155,7 +155,9 @@ export class StoreHost { async removeAccount(account: string, file: string): Promise { 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 { From a9b6a058410b13c5aa9d7f0ea0e72764b44628eb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:15:15 +0100 Subject: [PATCH 18/35] feat: enforce the desktop app's OS policies for plugins, mail servers and offline mail --- app/(main)/[locale]/login/page.tsx | 29 ++- .../extensions/[kind]/[id]/rollback/route.ts | 5 + app/api/admin/marketplace/route.ts | 6 + app/api/admin/plugin-approvals/route.ts | 6 + app/api/admin/plugins/[id]/bundle/route.ts | 4 + app/api/admin/plugins/route.ts | 10 + app/api/auth/session/route.ts | 4 + app/api/auth/stalwart-context/route.ts | 4 + app/api/auth/totp-token-exchange/route.ts | 4 + app/api/auth/verify/route.ts | 4 + app/api/plugins/route.ts | 6 + components/settings/addons-settings.tsx | 5 +- components/settings/device-plugins-off.tsx | 20 ++ components/settings/plugins-settings.tsx | 21 +- lib/__tests__/plugin-device-policy.test.ts | 193 ++++++++++++++++ lib/__tests__/plugin-sandbox-loader.test.ts | 23 ++ lib/admin/csp-frame-origins.ts | 3 +- lib/desktop/__tests__/policy-client.test.tsx | 102 +++++++++ .../__tests__/policy-enforcement.test.ts | 207 ++++++++++++++++++ lib/desktop/policy.ts | 84 +++++++ lib/desktop/server-policy.ts | 42 ++++ lib/desktop/types.ts | 17 +- lib/device-policy.ts | 5 +- lib/oauth/custom-upstream.ts | 2 + lib/plugin-sandbox/loader.ts | 7 + lib/plugin-send-guards.ts | 1 + locales/ar/common.json | 9 + locales/ca/common.json | 9 + locales/cs/common.json | 9 + locales/da/common.json | 9 + locales/de/common.json | 9 + locales/en/common.json | 9 + locales/es/common.json | 9 + locales/et/common.json | 9 + locales/fa/common.json | 9 + locales/fr/common.json | 9 + locales/he/common.json | 9 + locales/hu/common.json | 9 + locales/it/common.json | 9 + locales/ja/common.json | 9 + locales/ko/common.json | 9 + locales/lv/common.json | 9 + locales/mn/common.json | 9 + locales/nb/common.json | 9 + locales/nl/common.json | 9 + locales/pl/common.json | 9 + locales/pt/common.json | 9 + locales/ro/common.json | 9 + locales/ru/common.json | 9 + locales/sk/common.json | 9 + locales/th/common.json | 9 + locales/tr/common.json | 9 + locales/uk/common.json | 9 + locales/vi/common.json | 9 + locales/zh-TW/common.json | 9 + locales/zh/common.json | 9 + stores/plugin-store.ts | 36 ++- 57 files changed, 1108 insertions(+), 12 deletions(-) create mode 100644 components/settings/device-plugins-off.tsx create mode 100644 lib/__tests__/plugin-device-policy.test.ts create mode 100644 lib/desktop/__tests__/policy-client.test.tsx create mode 100644 lib/desktop/__tests__/policy-enforcement.test.ts create mode 100644 lib/desktop/policy.ts create mode 100644 lib/desktop/server-policy.ts diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index bf9b69c44..61d67df64 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -1,8 +1,9 @@ "use client"; -import { Suspense, useState, useEffect, useRef, useCallback, useId } from "react"; +import { Suspense, useState, useEffect, useRef, useCallback, useId, useMemo } from "react"; import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag"; import { forgetDesktopServer, getDesktopBridge } from "@/lib/desktop/bridge"; +import { customServerAllowedByDesktopPolicy, jmapServerAllowedByDesktopPolicy, useDesktopPolicy } from "@/lib/desktop/policy"; import { browserSignInAvailable, runDesktopBrowserSignIn } from "@/lib/desktop/browser-sign-in"; import { useRouter } from "@/i18n/navigation"; import { useParams, useSearchParams } from "next/navigation"; @@ -189,9 +190,12 @@ function LoginPageContent() { const router = useRouter(); const t = useTranslations("login"); const tDesktop = useTranslations("desktop_sign_in"); + const tDesktopPolicy = useTranslations("desktop_policy"); + const desktopPolicy = useDesktopPolicy(); // Only the desktop build sets these; a browser never shows them. const [desktopNotice, setDesktopNotice] = useState(null); const [desktopWaiting, setDesktopWaiting] = useState(false); + const [desktopPolicyNotice, setDesktopPolicyNotice] = useState(null); const tSessionLimits = useTranslations("session_limits"); const tOffline = useTranslations("offline"); const tTheme = useTranslations("settings.appearance.theme"); @@ -228,7 +232,14 @@ function LoginPageContent() { usePrefetchMailView(!isMobileHandoff); const { login, loginWithToken, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); - const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, oauthResource, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginUsernamePlaceholder, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowTokenLogin, loginShowRememberMe, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig(); + const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, oauthResource, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginUsernamePlaceholder, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowTokenLogin, loginShowRememberMe, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint: configAllowsCustomEndpoint, jmapServers: configuredServers, jmapServerAutoPickByDomain } = useConfig(); + // The device's OS policy narrows what the server offers. A forced Bulwark server hides + // the field for a server of one's own, unless that field is the only way to sign in there. + const allowCustomJmapEndpoint = configAllowsCustomEndpoint && (customServerAllowedByDesktopPolicy(desktopPolicy) || !configuredServerUrl); + const jmapServers = useMemo( + () => (desktopPolicy.AllowedJmapServers.value ? configuredServers.filter((s) => jmapServerAllowedByDesktopPolicy(s.url, desktopPolicy)) : configuredServers), + [configuredServers, desktopPolicy], + ); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const colorModeLocked = usePolicyStore((s) => s.getColorModePolicy().locked); @@ -1031,13 +1042,20 @@ function LoginPageContent() { // when the admin hasn't configured a server list. const effectiveServerUrl = selectedServer?.url || (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl); + // The device's OS policy, also on an instance: nothing is sent to a server it refuses. + setDesktopPolicyNotice(null); + if (!jmapServerAllowedByDesktopPolicy(effectiveServerUrl, desktopPolicy)) { + setDesktopPolicyNotice(tDesktopPolicy("server_refused")); + return; + } // A LAN or plain http server gets a native prompt first, and the app can // reach it from then on. Without a yes, nothing is sent there. if (IS_DESKTOP_BUILD) { setDesktopNotice(null); const prepared = await getDesktopBridge()?.servers?.prepare(effectiveServerUrl); if (prepared && !prepared.ok) { - setDesktopNotice(prepared.reason === "invalid" ? tDesktop("server_invalid") : tDesktop("server_declined")); + if (prepared.reason === "policy") setDesktopPolicyNotice(tDesktopPolicy("server_refused")); + else setDesktopNotice(prepared.reason === "invalid" ? tDesktop("server_invalid") : tDesktop("server_declined")); return; } } @@ -1880,6 +1898,11 @@ function LoginPageContent() { {desktopWaiting ? tDesktop("browser_waiting") : tDesktop("browser_button")} )} + {desktopPolicyNotice && ( +

    + {desktopPolicyNotice} +

    + )} {IS_DESKTOP_BUILD && desktopNotice && (

    {desktopNotice} diff --git a/app/api/admin/extensions/[kind]/[id]/rollback/route.ts b/app/api/admin/extensions/[kind]/[id]/rollback/route.ts index 651eefcb5..c89390d61 100644 --- a/app/api/admin/extensions/[kind]/[id]/rollback/route.ts +++ b/app/api/admin/extensions/[kind]/[id]/rollback/route.ts @@ -7,6 +7,7 @@ import { getPlugin, getTheme, savePlugin, saveTheme } from '@/lib/admin/plugin-r import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins'; import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; import { EXTENSION_ID_RE } from '@/lib/extension-package'; +import { devicePolicyPluginRefusal } from '@/lib/desktop/server-policy'; /** * POST /api/admin/extensions/[kind]/[id]/rollback @@ -32,6 +33,10 @@ export async function POST( return NextResponse.json({ error: 'Unknown extension kind' }, { status: 400 }); } if (!EXTENSION_ID_RE.test(id)) return NextResponse.json({ error: 'Invalid extension id' }, { status: 400 }); + if (kind === 'plugin') { + const refused = devicePolicyPluginRefusal(id); + if (refused) return refused; + } const body = await request.json().catch(() => ({})); const version = typeof body?.version === 'string' ? body.version.trim() : ''; diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index bea46fc9d..103f59a9f 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -44,6 +44,7 @@ import { } from '@/lib/admin/marketplace-directory'; import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types'; import { createHash } from 'node:crypto'; +import { devicePolicyPluginRefusal } from '@/lib/desktop/server-policy'; /** Query parameters the UI may pass on to the directory's list endpoint. */ const FORWARDED = ['q', 'type', 'pluginType', 'tag', 'author', 'sort', 'order', 'page', 'perPage', 'featured'] as const; @@ -161,6 +162,11 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid version: must look like 1.2.3' }, { status: 400 }); } + if (type === 'plugin') { + const refused = devicePolicyPluginRefusal(slug); + if (refused) return refused; + } + const directoryUrl = await getDirectoryUrl(); const versionPath = `/extensions/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`; diff --git a/app/api/admin/plugin-approvals/route.ts b/app/api/admin/plugin-approvals/route.ts index 4a2a8b26e..e570222df 100644 --- a/app/api/admin/plugin-approvals/route.ts +++ b/app/api/admin/plugin-approvals/route.ts @@ -5,6 +5,7 @@ import { logger } from '@/lib/logger'; import { listApprovals, decideApproval, revokeApproval } from '@/lib/admin/plugin-approvals'; import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; import { storeUnavailableResponse } from '@/lib/admin/store-unavailable-response'; +import { devicePolicyPluginRefusal } from '@/lib/desktop/server-policy'; /** * Admin-protected CRUD for the per-(pluginId, bundleHash) approval table. @@ -55,6 +56,11 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'decision must be "approved" or "denied"' }, { status: 400 }); } + if (b.decision === 'approved') { + const refused = devicePolicyPluginRefusal(b.pluginId); + if (refused) return refused; + } + const entry = await decideApproval(b.pluginId, b.bundleHash, b.decision, adminUser); await auditLog('plugin.approval', { pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision: entry.status }, ip); return NextResponse.json({ entry }); diff --git a/app/api/admin/plugins/[id]/bundle/route.ts b/app/api/admin/plugins/[id]/bundle/route.ts index 7264a74e9..f3f874214 100644 --- a/app/api/admin/plugins/[id]/bundle/route.ts +++ b/app/api/admin/plugins/[id]/bundle/route.ts @@ -4,6 +4,7 @@ import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev'; import { signBytes } from '@/lib/admin/plugin-signing'; import { canSeeInstanceDetails } from '@/lib/auth/instance-details'; import { storeUnavailableResponse } from '@/lib/admin/store-unavailable-response'; +import { devicePolicyPluginRefusal } from '@/lib/desktop/server-policy'; async function safeSign(code: string): Promise { try { return await signBytes(code); } catch { return null; } @@ -32,6 +33,9 @@ export async function GET( return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 }); } + const refused = devicePolicyPluginRefusal(id); + if (refused) return refused; + // Dev plugins are read (and optionally bundled) straight from disk and // served with no caching so every refresh picks up the latest source. const devEntry = await getDevPlugin(id); diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index e2f3a4312..8e331b8c5 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -17,6 +17,7 @@ import { keptVersions, listHistory } from '@/lib/admin/extension-history'; import { ExtensionPackageError, readPluginPackage, suspiciousCode } from '@/lib/extension-package'; import { MAX_PLUGIN_SIZE } from '@/lib/plugin-types'; import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; +import { devicePolicyPluginRefusal } from '@/lib/desktop/server-policy'; /** * GET /api/admin/plugins - List all admin-managed plugins @@ -87,6 +88,9 @@ export async function POST(request: NextRequest) { } const { manifest, code } = pkg; + const refused = devicePolicyPluginRefusal(manifest.id); + if (refused) return refused; + // Admin uploads reach every user, so the patterns a user upload only // warns about are refused here. const suspicious = suspiciousCode(code); @@ -160,6 +164,12 @@ export async function PATCH(request: NextRequest) { return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 }); } + // Turning one off stays possible: the policy only stops what would run. + if (enabled === true || forceEnabled === true) { + const refused = devicePolicyPluginRefusal(id); + if (refused) return refused; + } + const updates: { enabled?: boolean; forceEnabled?: boolean } = {}; if (typeof enabled === 'boolean') updates.enabled = enabled; if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled; diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 32b044ac2..93a7c7217 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -25,6 +25,7 @@ import { loginClientAddress } from '@/lib/auth/client-address'; import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget'; import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age'; +import { devicePolicyServerRefusal } from '@/lib/desktop/server-policy'; function sessionCookieOptions() { return { @@ -142,6 +143,9 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } + const refusedByDevice = devicePolicyServerRefusal(serverUrl); + if (refusedByDevice) return refusedByDevice; + // Pin the upstream URL to a configured JMAP server so an unauthenticated // caller cannot point this route at internal hosts. We accept the global // `jmapServerUrl` and any entry from `jmapServers`. When neither matches, diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts index 516f5ac11..23e07d21e 100644 --- a/app/api/auth/stalwart-context/route.ts +++ b/app/api/auth/stalwart-context/route.ts @@ -16,6 +16,7 @@ import { loginClientAddress } from '@/lib/auth/client-address'; import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget'; import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; import { recordStoredCredentialStatus } from '@/lib/stalwart/stored-credential-budget'; +import { devicePolicyServerRefusal } from '@/lib/desktop/server-policy'; function getSlot(request: NextRequest, bodySlot: unknown): number { if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS) { @@ -43,6 +44,9 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } + const refusedByDevice = devicePolicyServerRefusal(serverUrl); + if (refusedByDevice) return refusedByDevice; + // Pin the upstream URL to a configured JMAP server (single `jmapServerUrl` // or any entry in `jmapServers`). Falls back to the request URL only when // `allowCustomJmapEndpoint` is enabled, and even then it must be public. diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index a16be7089..6e3175881 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -18,6 +18,7 @@ import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-u import { writeSignInStamp } from '@/lib/auth/session-age'; import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; import { isDesktopTrustedServer } from '@/lib/desktop/trusted-servers'; +import { devicePolicyServerRefusal } from '@/lib/desktop/server-policy'; import { stalwartDefaultClientId } from '@/lib/auth/stalwart-password-login'; import { writeCustomUpstream } from '@/lib/oauth/custom-upstream'; @@ -162,6 +163,9 @@ export async function POST(request: NextRequest) { const serverList = parseJmapServers(configManager.get('jmapServers', [])); let upstreamUrl: string; + const refusedByDevice = devicePolicyServerRefusal(serverUrl); + if (refusedByDevice) return refusedByDevice; + let upstreamTrusted = true; let resolvedServerId: string | null = null; const requestedEntry = findServerById(serverList, requestedServerId); diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts index dd404e9a3..7a2f90703 100644 --- a/app/api/auth/verify/route.ts +++ b/app/api/auth/verify/route.ts @@ -8,6 +8,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { loginClientAddress } from '@/lib/auth/client-address'; import { reserveVerifyProbe, type BudgetRefund } from '@/lib/auth/verify-budget'; import { isStoreUnavailable } from '@/lib/auth/store-unavailable'; +import { devicePolicyServerRefusal } from '@/lib/desktop/server-policy'; /** * Server-side credential pre-check for the login form (#969): a password @@ -58,6 +59,9 @@ export async function POST(request: NextRequest) { return respond('inconclusive'); } + const refusedByDevice = devicePolicyServerRefusal(serverUrl); + if (refusedByDevice) return refusedByDevice; + // Same upstream pinning as /api/auth/session: an unauthenticated caller // must not be able to point this route at arbitrary internal hosts. const configuredServerUrl = diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 8a91d2e4b..6ae4551e6 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -7,6 +7,7 @@ import { logger } from '@/lib/logger'; import { storeUnavailableResponse } from '@/lib/admin/store-unavailable-response'; import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; import { pluginAllowedInMode } from '@/lib/outgoing-hosts'; +import { pluginRefusedByDevicePolicy } from '@/lib/desktop/server-policy'; /** * GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes @@ -66,6 +67,11 @@ export async function GET(request: NextRequest) { updatedAt: p.updatedAt, // Marks plugins loaded from PLUGIN_DEV_DIR. Surface in UI as a badge. dev: p.dev, + // The device's OS policy refuses this plugin. It stays on the list: a plugin + // missing from the list counts as removed by the admin, and the client then + // deletes its bundle, its data and its connected accounts. Tightening a policy + // must not destroy anything, and loosening it again brings all of it back. + ...(pluginRefusedByDevicePolicy(p.id) ? { refused: true } : {}), // Surface so clients can enforce api.http.fetch origin allowlists. httpOrigins: p.httpOrigins, // Surface so clients can enforce api.http.post path allowlists. diff --git a/components/settings/addons-settings.tsx b/components/settings/addons-settings.tsx index f3ad6bddf..0e5fc6419 100644 --- a/components/settings/addons-settings.tsx +++ b/components/settings/addons-settings.tsx @@ -3,6 +3,8 @@ import { SidebarAppsSettings } from './sidebar-apps-settings'; import { PluginsSettings } from './plugins-settings'; import { usePolicyStore } from '@/stores/policy-store'; +import { useDesktopPolicy } from '@/lib/desktop/policy'; +import { DevicePluginsOff } from './device-plugins-off'; /** * The Add-ons page: sidebar apps and plugins, the two ways to add things to @@ -11,10 +13,11 @@ import { usePolicyStore } from '@/stores/policy-store'; */ export function AddonsSettings() { const { isFeatureEnabled } = usePolicyStore(); + const devicePolicy = useDesktopPolicy(); return (

    {isFeatureEnabled('sidebarAppsEnabled') && } - {isFeatureEnabled('pluginsEnabled') && } + {isFeatureEnabled('pluginsEnabled') ? : devicePolicy.PluginsMode.value === 'off' && }
    ); } diff --git a/components/settings/device-plugins-off.tsx b/components/settings/device-plugins-off.tsx new file mode 100644 index 000000000..2d344cdbf --- /dev/null +++ b/components/settings/device-plugins-off.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useTranslations } from 'next-intl'; +import { SettingItem, SettingsSection } from './settings-section'; + +/** + * The plugins part of the Add-ons page when the device's OS policy turns plugins + * off: says so, where the list would be, instead of the section just not being there. + */ +export function DevicePluginsOff() { + const t = useTranslations('desktop_policy'); + const tPlugins = useTranslations('plugins.settings'); + return ( + + + {t('off')} + + + ); +} diff --git a/components/settings/plugins-settings.tsx b/components/settings/plugins-settings.tsx index e1ddcd46b..105f782b4 100644 --- a/components/settings/plugins-settings.tsx +++ b/components/settings/plugins-settings.tsx @@ -15,6 +15,8 @@ import { PluginPermissionList } from '@/components/plugins/plugin-permission-lis import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types'; import { effectiveSettings } from '@/lib/plugin-settings'; import { PLUGIN_REFUSED_ERROR } from '@/lib/outgoing-hosts'; +import { PLUGIN_POLICY_REFUSED_ERROR, useDesktopPolicy } from '@/lib/desktop/policy'; +import { OrgLockedNote } from './org-locked-note'; import { lookup } from '@/lib/plugin-sandbox/plugin-i18n'; import { forgetCredentials, listConnectedAccounts, type ConnectedAccount } from '@/lib/plugin-sandbox/connect-dialog'; import { forwardApprovals, revokeForwardApproval } from '@/lib/plugin-sandbox/forward-approvals'; @@ -35,6 +37,8 @@ export function PluginsSettings() { const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore(); const { isFeatureEnabled, isPluginForceEnabled, fetchPolicy, loaded } = usePolicyStore(); const { demoMode } = useConfig(); + const tDevicePolicyPage = useTranslations('desktop_policy'); + const devicePolicy = useDesktopPolicy(); const [expandedPlugin, setExpandedPlugin] = useState(null); const [installing, setInstalling] = useState(false); const [installError, setInstallError] = useState(null); @@ -64,7 +68,7 @@ export function PluginsSettings() { return null; } - const uploadsAllowed = isFeatureEnabled('pluginsUploadEnabled') && !demoMode; + const uploadsAllowed = isFeatureEnabled('pluginsUploadEnabled') && !demoMode && devicePolicy.PluginsMode.value !== 'off'; const requireApproval = isFeatureEnabled('requirePluginApproval'); const handleToggle = async (plugin: InstalledPlugin) => { @@ -97,7 +101,8 @@ export function PluginsSettings() { const result = await installPlugin(file); if (!result.success) { setInstallError( - result.reason === 'uploads-disabled' ? t('install_uploads_off') + result.reason === 'device-policy' ? tDevicePolicyPage('install_refused') + : result.reason === 'uploads-disabled' ? t('install_uploads_off') : result.reason === 'managed-id' ? t('install_managed_id') : t('install_invalid', { details: result.error ?? '' }), ); @@ -118,6 +123,14 @@ export function PluginsSettings() { title={t('title')} description={demoMode ? t('description_demo') : uploadsAllowed ? t('description_uploads') : t('description')} > + {devicePolicy.PluginsMode.locked && devicePolicy.PluginsMode.value !== 'allowed' && ( +
    +

    + {tDevicePolicyPage(devicePolicy.PluginsMode.value === 'off' ? 'plugins_off' : 'plugins_allowlist')} +

    + +
    + )} {uploadsAllowed && (
    @@ -216,12 +229,14 @@ interface PluginCardProps { function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onToggleExpand, onToggle, onRemove, onUpdateSettings }: PluginCardProps) { const t = useTranslations('plugins.settings'); const tThirdParty = useTranslations('third_party'); + const tDevicePolicy = useTranslations('desktop_policy'); const locale = useLocale(); const isManaged = Boolean(plugin.managed); const awaiting = !isManaged && plugin.approvalStatus === 'pending'; const denied = !isManaged && plugin.approvalStatus === 'denied'; const errorText = plugin.error - ? plugin.error === PLUGIN_REFUSED_ERROR ? tThirdParty('plugin_refused') + ? plugin.error === PLUGIN_POLICY_REFUSED_ERROR ? tDevicePolicy('plugin_refused') + : plugin.error === PLUGIN_REFUSED_ERROR ? tThirdParty('plugin_refused') : ERROR_CODES.has(plugin.error) ? t(`error_${plugin.error.replace(/-/g, '_')}`) : plugin.error : null; diff --git a/lib/__tests__/plugin-device-policy.test.ts b/lib/__tests__/plugin-device-policy.test.ts new file mode 100644 index 000000000..63c0a0ded --- /dev/null +++ b/lib/__tests__/plugin-device-policy.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { InstalledPlugin } from '@/lib/plugin-types'; +import { EMPTY_POLICY_LAYERS, resolvePolicies, type PolicyLayer } from '@/lib/desktop/policy-schema'; + +/** + * PluginsMode on the client: the only gate there is on an instance. And the trap: a + * plugin missing from /api/plugins counts as removed by the admin, which deletes its + * bundle, data and connected accounts. Tightening a policy must not do that. + */ + +vi.mock('@/lib/plugin-storage', () => ({ + pluginStorage: { + saveCode: vi.fn().mockResolvedValue(undefined), + getCode: vi.fn().mockResolvedValue('code'), + codeIds: vi.fn().mockResolvedValue(['kept', 'refused-one']), + deleteCode: vi.fn().mockResolvedValue(undefined), + saveThemeCSS: vi.fn().mockResolvedValue(undefined), + getThemeCSS: vi.fn().mockResolvedValue(null), + deleteThemeCSS: vi.fn().mockResolvedValue(undefined), + savePreview: vi.fn().mockResolvedValue(undefined), + getPreview: vi.fn().mockResolvedValue(null), + deletePreview: vi.fn().mockResolvedValue(undefined), + }, + fileStorage: { clearFiles: vi.fn().mockResolvedValue(undefined) }, +})); +vi.mock('@/lib/plugin-validator', () => ({ extractPlugin: vi.fn() })); +vi.mock('@/lib/plugin-loader', () => ({ + loadPlugin: vi.fn().mockResolvedValue(undefined), + deactivatePlugin: vi.fn(), + setPluginStoreAccessor: vi.fn(), + setupAutoDisable: vi.fn(), + setSandboxLocale: vi.fn(), + forgetSlotOffers: vi.fn(), +})); +vi.mock('@/lib/plugin-hooks', () => ({ removeAllPluginHooks: vi.fn() })); + +const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), downloadManagedBundle: vi.fn() })); + +// In the app the bridge is there before any page code runs, and the store subscribes +// when it's first imported. So it's here before the imports below. +const harness = vi.hoisted(() => { + const state = { + current: null as unknown, + listeners: [] as ((snapshot: unknown) => void)[], + }; + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { + version: 1, + platform: 'win32', + policy: { + get: () => state.current, + onChange: (listener: (snapshot: unknown) => void) => { + state.listeners.push(listener); + return () => {}; + }, + onWipeOfflineData: () => () => {}, + }, + }; + return state; +}); +vi.mock('@/lib/browser-navigation', () => ({ apiFetch: (...args: unknown[]) => mocks.apiFetch(...args) })); +vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({ + forEachInstance: () => {}, + snapshotRegionalSettings: vi.fn((locale: string) => ({ locale, dateLocale: locale, hour12: false, timeZone: 'UTC', firstDayOfWeek: 1 })), +})); +vi.mock('@/lib/plugin-sandbox/bundle-fetch', () => ({ downloadManagedBundle: (...args: unknown[]) => mocks.downloadManagedBundle(...args) })); + +import { register as registerActive, deregister as deregisterActive } from '@/lib/plugin-sandbox/registry'; +import { usePluginStore } from '@/stores/plugin-store'; +import { pluginStorage } from '@/lib/plugin-storage'; +import { deactivatePlugin, forgetSlotOffers, loadPlugin } from '@/lib/plugin-loader'; +import { extractPlugin } from '@/lib/plugin-validator'; + +function snapshotOf(forcedMachine: PolicyLayer) { + return resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine }); +} + +function setPolicy(forced: PolicyLayer) { + harness.current = snapshotOf(forced); + for (const listener of harness.listeners) listener(harness.current); +} + +function plugin(overrides: Partial = {}): InstalledPlugin { + return { + id: 'refused-one', name: 'R', version: '1.0.0', author: 'A', description: '', type: 'hook', entrypoint: 'index.js', + permissions: [], enabled: true, status: 'running', managed: true, settings: { mine: 'kept' }, ...overrides, + }; +} + +const listed = (id: string, extra: object = {}) => ({ + id, name: id, version: '1.0.0', author: 'A', description: '', type: 'hook', entrypoint: 'index.js', permissions: [], forceEnabled: false, bundleHash: 'hash', ...extra, +}); + +function serve(plugins: unknown[]) { + mocks.apiFetch.mockImplementation(async () => new Response(JSON.stringify({ plugins, themes: [] }))); +} + +beforeEach(() => { + vi.clearAllMocks(); + usePluginStore.setState({ plugins: [], initialized: false }); + harness.current = snapshotOf({}); + localStorage.clear(); + usePluginStore.setState({ plugins: [], initialized: false }); +}); + +describe('tightening the policy destroys nothing', () => { + function expectNothingDeleted(id: string) { + expect(usePluginStore.getState().plugins.find((p) => p.id === id)).toMatchObject({ id, settings: { mine: 'kept' } }); + expect(localStorage.getItem(`plugin:${id}:@acct:notes`)).toBe('"secret"'); + expect(pluginStorage.deleteCode).not.toHaveBeenCalledWith(id); + expect(pluginStorage.deletePreview).not.toHaveBeenCalledWith(id); + expect(forgetSlotOffers).not.toHaveBeenCalledWith(id); + } + + beforeEach(() => { + localStorage.setItem('server-managed-plugin-ids', JSON.stringify(['refused-one'])); + localStorage.setItem('plugin:refused-one:@acct:notes', '"secret"'); + usePluginStore.setState({ plugins: [plugin()] }); + }); + + it('the embedded server flags it: record, data and bundle stay, and nothing is downloaded', async () => { + setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['kept'] }); + serve([listed('refused-one', { refused: true, version: '2.0.0', bundleHash: 'changed' })]); + await usePluginStore.getState().initializePlugins(); + expectNothingDeleted('refused-one'); + expect(usePluginStore.getState().plugins[0].version).toBe('1.0.0'); + expect(mocks.downloadManagedBundle).not.toHaveBeenCalled(); + }); + + it('an instance knows nothing of the policy and lists it plainly: the client refuses by itself', async () => { + setPolicy({ PluginsMode: 'off' }); + serve([listed('refused-one', { version: '2.0.0', bundleHash: 'changed' })]); + await usePluginStore.getState().initializePlugins(); + expectNothingDeleted('refused-one'); + expect(mocks.downloadManagedBundle).not.toHaveBeenCalled(); + }); + + it('a plugin the admin really removed still goes, with the policy on', async () => { + setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['kept'] }); + serve([]); + await usePluginStore.getState().initializePlugins(); + expect(usePluginStore.getState().plugins.some((p) => p.id === 'refused-one')).toBe(false); + expect(localStorage.getItem('plugin:refused-one:@acct:notes')).toBeNull(); + }); + + it('a change while running stops it and keeps everything, and lifting it starts it again', async () => { + registerActive({ plugin: plugin(), code: 'code', tier: 'untrusted', background: { destroy: vi.fn() }, slotOffers: [], hookDisposables: [] } as never); + setPolicy({ PluginsMode: 'off' }); + expect(deactivatePlugin).toHaveBeenCalledWith('refused-one'); + expect(usePluginStore.getState().plugins[0]).toMatchObject({ enabled: true, status: 'error', error: 'device-policy' }); + expectNothingDeleted('refused-one'); + deregisterActive('refused-one'); + + setPolicy({}); + expect(loadPlugin).toHaveBeenCalledWith(expect.objectContaining({ id: 'refused-one' })); + expect(usePluginStore.getState().plugins[0].error).toBeUndefined(); + }); +}); + +describe('the client gates', () => { + it('a new plugin the policy refuses isn\'t added or downloaded', async () => { + setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['kept'] }); + serve([listed('refused-one')]); + mocks.downloadManagedBundle.mockResolvedValue('code'); + await usePluginStore.getState().initializePlugins(); + expect(usePluginStore.getState().plugins).toEqual([]); + expect(mocks.downloadManagedBundle).not.toHaveBeenCalled(); + }); + + it('an allowed one is', async () => { + setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['kept'] }); + serve([listed('kept')]); + mocks.downloadManagedBundle.mockResolvedValue('code'); + await usePluginStore.getState().initializePlugins(); + expect(usePluginStore.getState().plugins.map((p) => p.id)).toEqual(['kept']); + }); + + it('installing a package is refused by the id in its manifest', async () => { + setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['kept'] }); + vi.mocked(extractPlugin).mockResolvedValue({ valid: true, errors: [], warnings: [], manifest: { id: 'refused-one' }, code: 'x' } as never); + const result = await usePluginStore.getState().installPlugin(new File(['x'], 'p.zip')); + expect(result).toMatchObject({ success: false, reason: 'device-policy' }); + expect(usePluginStore.getState().plugins).toEqual([]); + expect(pluginStorage.saveCode).not.toHaveBeenCalled(); + }); + + it('turning one on is refused, and says why', async () => { + setPolicy({ PluginsMode: 'off' }); + usePluginStore.setState({ plugins: [plugin({ enabled: false, status: 'installed' })] }); + await usePluginStore.getState().enablePlugin('refused-one'); + expect(usePluginStore.getState().plugins[0]).toMatchObject({ enabled: false, status: 'error', error: 'device-policy' }); + expect(loadPlugin).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/__tests__/plugin-sandbox-loader.test.ts b/lib/__tests__/plugin-sandbox-loader.test.ts index ddb2fc49b..4614e23e3 100644 --- a/lib/__tests__/plugin-sandbox-loader.test.ts +++ b/lib/__tests__/plugin-sandbox-loader.test.ts @@ -102,6 +102,29 @@ function lastStatus() { return statuses[statuses.length - 1]; } +describe('loadSandboxedPlugin under the OS policy of the device', () => { + it('starts nothing the policy refuses: no frame, no download', async () => { + const { EMPTY_POLICY_LAYERS, resolvePolicies } = await import('../desktop/policy-schema'); + const snapshot = (forcedMachine: Record) => resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine }); + let current = snapshot({ PluginsMode: 'allowlist', PluginAllowlist: ['other-plugin'] }); + window.bulwarkDesktop = { version: 1, platform: 'win32', policy: { get: () => current, onChange: () => () => {}, onWipeOfflineData: () => () => {} } }; + mocks.getCode.mockResolvedValue(CODE); + try { + await loadSandboxedPlugin(plugin()); + expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'error', error: 'device-policy' }); + expect(mocks.createBackgroundInstance).not.toHaveBeenCalled(); + expect(mocks.downloadManagedBundle).not.toHaveBeenCalled(); + expect(mocks.getCode).not.toHaveBeenCalled(); + + current = snapshot({ PluginsMode: 'allowlist', PluginAllowlist: ['managed-plugin'] }); + await loadSandboxedPlugin(plugin()); + expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'running' }); + } finally { + window.bulwarkDesktop = undefined; + } + }); +}); + describe('loadSandboxedPlugin in the no-third-party mode (#903)', () => { it('refuses a plugin that declares network access and runs one that does not', async () => { const { setNoThirdPartyTraffic } = await import('../third-party-mode'); diff --git a/lib/admin/csp-frame-origins.ts b/lib/admin/csp-frame-origins.ts index f80b9b1c7..e9eb21d36 100644 --- a/lib/admin/csp-frame-origins.ts +++ b/lib/admin/csp-frame-origins.ts @@ -13,6 +13,7 @@ import { getPluginRegistry } from './plugin-registry'; import { sanitizeFrameOrigins } from '../extension-origins'; import { isNoThirdPartyTrafficOn } from './third-party-mode'; import { onSharedChange } from '@/lib/shared-store/changes'; +import { pluginRefusedByDevicePolicy } from '@/lib/desktop/server-policy'; export { isValidFrameOrigin, @@ -79,7 +80,7 @@ export async function getEnabledPluginFrameOrigins(): Promise { const seen = new Set(); const out: string[] = []; for (const plugin of [...registry.plugins, ...devPlugins]) { - if (!plugin.enabled) continue; + if (!plugin.enabled || pluginRefusedByDevicePolicy(plugin.id)) continue; const origins = sanitizeFrameOrigins(plugin.frameOrigins); for (const o of origins) { const key = o.toLowerCase(); diff --git a/lib/desktop/__tests__/policy-client.test.tsx b/lib/desktop/__tests__/policy-client.test.tsx new file mode 100644 index 000000000..6d81f29d5 --- /dev/null +++ b/lib/desktop/__tests__/policy-client.test.tsx @@ -0,0 +1,102 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { BulwarkDesktopBridge } from '../types'; +import { EMPTY_POLICY_LAYERS, resolvePolicies, type PolicyLayer, type PolicySnapshot } from '../policy-schema'; +import { customServerAllowedByDesktopPolicy, desktopOfflinePolicy, desktopPolicyValue, jmapServerAllowedByDesktopPolicy, pluginAllowedByDesktopPolicy, useDesktopPolicy } from '../policy'; + +function snapshotOf(forcedMachine: PolicyLayer, recommendedMachine: PolicyLayer = {}): PolicySnapshot { + return resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine, recommendedMachine }); +} + +let current: PolicySnapshot; +let listeners: ((snapshot: PolicySnapshot) => void)[]; + +function setBridgePolicy(snapshot: PolicySnapshot) { + current = snapshot; + listeners = []; + window.bulwarkDesktop = { + version: 1, + platform: 'win32', + mode: 'instance', + policy: { + get: () => current, + onChange: (listener) => { + listeners.push(listener); + return () => { listeners = listeners.filter((l) => l !== listener); }; + }, + onWipeOfflineData: () => () => {}, + }, + } as BulwarkDesktopBridge; +} + +afterEach(() => { + window.bulwarkDesktop = undefined; +}); + +describe('in a browser', () => { + it('nothing is refused and nothing is locked', () => { + expect(pluginAllowedByDesktopPolicy('anything')).toBe(true); + expect(jmapServerAllowedByDesktopPolicy('https://anything.example')).toBe(true); + expect(customServerAllowedByDesktopPolicy()).toBe(true); + expect(desktopOfflinePolicy()).toEqual({ forced: null, fallback: null }); + }); + + it('a shell that predates the policy member counts as no policy', () => { + window.bulwarkDesktop = { version: 1, platform: 'win32' } as BulwarkDesktopBridge; + expect(pluginAllowedByDesktopPolicy('anything')).toBe(true); + }); +}); + +describe('in the app', () => { + it('follows what the bridge says', () => { + setBridgePolicy(snapshotOf({ PluginsMode: 'allowlist', PluginAllowlist: ['good'], AllowedJmapServers: ['https://mail.example.com'] })); + expect(pluginAllowedByDesktopPolicy('good')).toBe(true); + expect(pluginAllowedByDesktopPolicy('bad')).toBe(false); + expect(jmapServerAllowedByDesktopPolicy('https://mail.example.com/x')).toBe(true); + expect(jmapServerAllowedByDesktopPolicy('https://other.example')).toBe(false); + }); + + it('a forced server hides the field for one of your own, unless direct connections are left on', () => { + setBridgePolicy(snapshotOf({ InstanceUrl: 'https://mail.example.com' })); + expect(customServerAllowedByDesktopPolicy()).toBe(false); + setBridgePolicy(snapshotOf({ InstanceUrl: 'https://mail.example.com', AllowStandalone: 1 })); + expect(customServerAllowedByDesktopPolicy()).toBe(true); + setBridgePolicy(snapshotOf({ AllowStandalone: 0 })); + expect(customServerAllowedByDesktopPolicy()).toBe(false); + setBridgePolicy(snapshotOf({}, { InstanceUrl: 'https://mail.example.com' })); + expect(customServerAllowedByDesktopPolicy()).toBe(true); + }); + + it('forced offline goes over the user, recommended under', () => { + setBridgePolicy(snapshotOf({ OfflineEnabled: 0 })); + expect(desktopOfflinePolicy()).toEqual({ forced: false, fallback: null }); + expect(desktopPolicyValue('OfflineEnabled', true)).toBe(false); + setBridgePolicy(snapshotOf({}, { OfflineEnabled: 0 })); + expect(desktopOfflinePolicy()).toEqual({ forced: null, fallback: false }); + expect(desktopPolicyValue('OfflineEnabled', true)).toBe(true); + expect(desktopPolicyValue('OfflineEnabled')).toBe(false); + }); + + it('forced off is the same as the organisation forbidding it, for every account', async () => { + setBridgePolicy(snapshotOf({ OfflineEnabled: 0 })); + const { devicesForAccount } = await import('@/lib/device-policy'); + expect(devicesForAccount('some-account').noOffline).toBe(true); + setBridgePolicy(snapshotOf({})); + expect(devicesForAccount('some-account').noOffline).toBe(false); + }, 60_000); + + it('the hook shows a change at once', async () => { + setBridgePolicy(snapshotOf({})); + function Probe() { + return {useDesktopPolicy().PluginsMode.value}; + } + render(); + expect(screen.getByText('allowed')).toBeInTheDocument(); + const { act } = await import('@testing-library/react'); + act(() => { + current = snapshotOf({ PluginsMode: 'off' }); + for (const listener of listeners) listener(current); + }); + expect(screen.getByText('off')).toBeInTheDocument(); + }); +}); diff --git a/lib/desktop/__tests__/policy-enforcement.test.ts b/lib/desktop/__tests__/policy-enforcement.test.ts new file mode 100644 index 000000000..cffd202a8 --- /dev/null +++ b/lib/desktop/__tests__/policy-enforcement.test.ts @@ -0,0 +1,207 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import type { ServerPolicy } from '../policy-schema'; + +/** + * PluginsMode in the install path, on the server. The five ways a plugin gets + * installed or switched on, the serving points, and what tightening leaves alone. + * Every route is the real one; only storage and the session are stand-ins. + */ + +vi.mock('@/lib/admin/session', () => ({ + requireAdminAuth: async () => ({ payload: { role: 'admin' } }), + getClientIP: () => '127.0.0.1', +})); +vi.mock('@/lib/admin/audit', () => ({ auditLog: async () => {} })); +vi.mock('@/lib/auth/instance-details', () => ({ canSeeInstanceDetails: async () => true })); +vi.mock('@/lib/admin/third-party-mode', () => ({ isNoThirdPartyTrafficOn: async () => false })); +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + get: () => undefined, + getPolicy: () => ({ forceEnabledPlugins: [], features: {} }), + }, +})); + +const registry = vi.hoisted(() => ({ + plugins: [] as Record[], + saved: [] as string[], + decided: [] as string[], + updated: [] as unknown[], +})); + +vi.mock('@/lib/admin/plugin-registry', () => ({ + getPlugin: async (id: string) => registry.plugins.find((p) => p.id === id) ?? null, + getTheme: async () => null, + getPluginRegistry: async () => ({ plugins: registry.plugins }), + getThemeRegistry: async () => ({ themes: [] }), + savePlugin: vi.fn(async (plugin: { id: string }) => { registry.saved.push(plugin.id); }), + saveTheme: vi.fn(), + deletePlugin: vi.fn(), + updatePluginMeta: vi.fn(async (id: string, updates: unknown) => { registry.updated.push([id, updates]); return { id, ...(updates as object) }; }), + getPluginBundle: async () => 'code', + isBundleMismatch: () => false, + getPluginSignature: async () => null, + readHistoryEntry: async () => ({ entry: { id: 'x', version: '0.9.0' }, code: 'old' }), + themeCSSResolver: async () => () => null, +})); +vi.mock('@/lib/admin/plugin-dev', () => ({ + listDevPlugins: async () => [], + getDevPlugin: async () => null, + readDevBundle: async () => '', +})); +vi.mock('@/lib/admin/plugin-signing', () => ({ signBytes: async () => 'sig' })); +vi.mock('@/lib/admin/plugin-approvals', async (original) => ({ + ...(await original()), + decideApproval: vi.fn(async (pluginId: string) => { registry.decided.push(pluginId); return { pluginId, bundleHash: 'a'.repeat(64), status: 'approved' }; }), +})); +vi.mock('@/lib/admin/csp-frame-origins', async (original) => { + const real = await original(); + return { ...real, invalidateFrameOriginsCache: () => {} }; +}); + +// Every address counts as public here, so only the policy can be what refuses one. +vi.mock('@/lib/security/url-guard', async (original) => ({ + ...(await original()), + isPublicHttpUrl: async () => true, +})); + +const POLICY_KEY = Symbol.for('bulwark.desktop.serverPolicy'); + +function setPolicy(policy: Partial | undefined) { + (globalThis as unknown as Record)[POLICY_KEY] = policy && { pluginAllowlist: [], allowedJmapServers: null, ...policy }; +} + +function jsonRequest(url: string, method: string, body: unknown) { + return new NextRequest(`http://localhost${url}`, { + method, + body: JSON.stringify(body), + headers: { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' }, + }); +} + +beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + vi.resetModules(); + registry.plugins = [{ id: 'kept', name: 'Kept', version: '1.0.0', enabled: true, frameOrigins: ['https://kept.example'] }, { id: 'other', name: 'Other', version: '1.0.0', enabled: true, frameOrigins: ['https://other.example'] }]; + registry.saved = []; + registry.decided = []; + registry.updated = []; + // A route that gets past the gate tries the directory. It must not reach a network. + vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 502 }))); +}); + +afterEach(() => { + setPolicy(undefined); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe.each(['off', 'allowlist'] as const)('with PluginsMode %s and the plugin not on the list', (pluginsMode) => { + beforeEach(() => setPolicy({ pluginsMode, pluginAllowlist: pluginsMode === 'allowlist' ? ['kept'] : [] })); + + it('the marketplace refuses before anyone asks the directory', async () => { + const { POST } = await import('@/app/api/admin/marketplace/route'); + const answer = await POST(jsonRequest('/api/admin/marketplace', 'POST', { slug: 'other', version: '1.0.0', type: 'plugin' })); + expect(answer.status).toBe(403); + expect((await answer.json()).code).toBe('blocked_by_policy'); + expect(fetch).not.toHaveBeenCalled(); + expect(registry.saved).toEqual([]); + }); + + it('PATCH will not switch it on, or force it on', async () => { + const { PATCH } = await import('@/app/api/admin/plugins/route'); + for (const body of [{ id: 'other', enabled: true }, { id: 'other', forceEnabled: true }]) { + const answer = await PATCH(jsonRequest('/api/admin/plugins', 'PATCH', body)); + expect(answer.status).toBe(403); + } + expect(registry.updated).toEqual([]); + }); + + it('PATCH can still switch it off', async () => { + const { PATCH } = await import('@/app/api/admin/plugins/route'); + const answer = await PATCH(jsonRequest('/api/admin/plugins', 'PATCH', { id: 'other', enabled: false })); + expect(answer.status).toBe(200); + }); + + it('a rollback is refused', async () => { + const { POST } = await import('@/app/api/admin/extensions/[kind]/[id]/rollback/route'); + const answer = await POST(jsonRequest('/x', 'POST', { version: '0.9.0' }), { params: Promise.resolve({ kind: 'plugin', id: 'other' }) }); + expect(answer.status).toBe(403); + expect(registry.saved).toEqual([]); + }); + + it('approving a user\'s package is refused, and denying it is not', async () => { + const { POST } = await import('@/app/api/admin/plugin-approvals/route'); + const hash = 'a'.repeat(64); + expect((await POST(jsonRequest('/x', 'POST', { pluginId: 'other', bundleHash: hash, decision: 'approved' }))).status).toBe(403); + expect(registry.decided).toEqual([]); + expect((await POST(jsonRequest('/x', 'POST', { pluginId: 'other', bundleHash: hash, decision: 'denied' }))).status).toBe(200); + }); + + it('the bundle is not served', async () => { + const { GET } = await import('@/app/api/admin/plugins/[id]/bundle/route'); + const answer = await GET(new NextRequest('http://localhost/api/admin/plugins/other/bundle'), { params: Promise.resolve({ id: 'other' }) }); + expect(answer.status).toBe(403); + }); + + it('its frame origins are not in the CSP', async () => { + delete (globalThis as unknown as Record)[Symbol.for('bulwark.admin.cspFrameOrigins')]; + const { getEnabledPluginFrameOrigins } = await import('@/lib/admin/csp-frame-origins'); + const origins = await getEnabledPluginFrameOrigins(); + expect(origins).not.toContain('https://other.example'); + expect(origins.includes('https://kept.example')).toBe(pluginsMode === 'allowlist'); + }); + + it('the list still names it, flagged as refused, so nothing counts it as removed', async () => { + const { GET } = await import('@/app/api/plugins/route'); + const answer = await GET(new NextRequest('http://localhost/api/plugins')); + const { plugins } = await answer.json() as { plugins: { id: string; refused?: boolean }[] }; + expect(plugins.map((p) => p.id).sort()).toEqual(['kept', 'other']); + expect(plugins.find((p) => p.id === 'other')!.refused).toBe(true); + expect(plugins.find((p) => p.id === 'kept')!.refused === true).toBe(pluginsMode === 'off'); + }); +}); + +describe('what is allowed goes through', () => { + it('an allowlisted plugin passes the gate', async () => { + setPolicy({ pluginsMode: 'allowlist', pluginAllowlist: ['kept'] }); + const { PATCH } = await import('@/app/api/admin/plugins/route'); + const patched = await PATCH(jsonRequest('/api/admin/plugins', 'PATCH', { id: 'kept', enabled: true })); + expect(await patched.json()).toMatchObject({ plugin: { id: 'kept', enabled: true } }); + expect(patched.status).toBe(200); + const { GET } = await import('@/app/api/admin/plugins/[id]/bundle/route'); + expect((await GET(new NextRequest('http://localhost/x'), { params: Promise.resolve({ id: 'kept' }) })).status).toBe(200); + }); + + it('a build without the desktop flag ignores all of it, even with a value on globalThis', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', ''); + setPolicy({ pluginsMode: 'off' }); + const { PATCH } = await import('@/app/api/admin/plugins/route'); + expect((await PATCH(jsonRequest('/api/admin/plugins', 'PATCH', { id: 'other', enabled: true }))).status).toBe(200); + const { GET } = await import('@/app/api/plugins/route'); + const { plugins } = await (await GET(new NextRequest('http://localhost/api/plugins'))).json() as { plugins: { refused?: boolean }[] }; + expect(plugins.every((p) => !p.refused)).toBe(true); + }); +}); + +describe('AllowedJmapServers on the sign-in routes', () => { + it('refuses a server off the list before contacting it', async () => { + setPolicy({ pluginsMode: 'allowed', allowedJmapServers: ['https://mail.example.com'] }); + const { POST } = await import('@/app/api/auth/session/route'); + const answer = await POST(jsonRequest('/api/auth/session', 'POST', { serverUrl: 'https://other.example.org', username: 'a', password: 'b' })); + expect(answer.status).toBe(403); + expect((await answer.json()).code).toBe('blocked_by_policy'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('the browser sign-in\'s lookup refuses it too', async () => { + setPolicy({ pluginsMode: 'allowed', allowedJmapServers: ['https://mail.example.com'] }); + const { resolveCustomUpstream } = await import('@/lib/oauth/custom-upstream'); + expect(await resolveCustomUpstream('https://other.example.org', 'client')).toBeNull(); + expect(await resolveCustomUpstream('https://mail.example.com', 'client')).toMatchObject({ serverUrl: 'https://mail.example.com' }); + }); +}); diff --git a/lib/desktop/policy.ts b/lib/desktop/policy.ts new file mode 100644 index 000000000..63b1d4f72 --- /dev/null +++ b/lib/desktop/policy.ts @@ -0,0 +1,84 @@ +/** + * The OS policies as a page sees them, through the desktop app's `policy` bridge. + * In a browser, or under a shell that predates this, there's no policy: everything + * here says "allowed" and "not locked", so browsers behave as they always did. + * + * Features ask here rather than reading the bridge themselves. + */ + +import { useSyncExternalStore } from 'react'; +import { getDesktopBridge } from './bridge'; +import { + DEFAULT_POLICY_SNAPSHOT, + effectivePolicyValue, + jmapServerAllowed, + pluginAllowedByPolicy, + type PolicyEntry, + type PolicyKey, + type PolicySnapshot, +} from './policy-schema'; + +/** A plugin the device's policy refuses, on a plugin's record and in the settings page. */ +export const PLUGIN_POLICY_REFUSED_ERROR = 'device-policy'; + +export function getDesktopPolicy(): PolicySnapshot { + return getDesktopBridge()?.policy?.get?.() ?? DEFAULT_POLICY_SNAPSHOT; +} + +export function onDesktopPolicyChange(listener: (snapshot: PolicySnapshot) => void): () => void { + return getDesktopBridge()?.policy?.onChange?.(listener) ?? (() => {}); +} + +/** + * Asks to hear about a forced WipeOfflineData value. The store's wipe goes here: + * when the handler's promise resolves, the shell remembers the value for good. + */ +export function onWipeOfflineData(handler: (nonce: string) => Promise): () => void { + return getDesktopBridge()?.policy?.onWipeOfflineData?.(handler) ?? (() => {}); +} + +const serverSnapshot = () => DEFAULT_POLICY_SNAPSHOT; + +/** The policies, live, for a component. */ +export function useDesktopPolicy(): PolicySnapshot { + return useSyncExternalStore(onDesktopPolicyChange, getDesktopPolicy, serverSnapshot); +} + +export function desktopPolicyEntry(key: K): PolicyEntry { + return getDesktopPolicy()[key]; +} + +/** Forced beats the user's choice, which beats a recommendation. See policy-schema.ts. */ +export function desktopPolicyValue(key: K, userChoice?: PolicySnapshot[K]['value'] | null): PolicySnapshot[K]['value'] { + return effectivePolicyValue(desktopPolicyEntry(key), userChoice) as PolicySnapshot[K]['value']; +} + +export function pluginAllowedByDesktopPolicy(id: string, snapshot: PolicySnapshot = getDesktopPolicy()): boolean { + return pluginAllowedByPolicy(snapshot.PluginsMode.value, snapshot.PluginAllowlist.value, id); +} + +export function jmapServerAllowedByDesktopPolicy(serverUrl: string, snapshot: PolicySnapshot = getDesktopPolicy()): boolean { + return jmapServerAllowed(snapshot.AllowedJmapServers.value, serverUrl); +} + +/** + * Whether the login page may offer a server of the user's own. A forced Bulwark + * server decides that alone, unless the same policy leaves direct connections on. + * `AllowStandalone` off says the same. + */ +export function customServerAllowedByDesktopPolicy(snapshot: PolicySnapshot = getDesktopPolicy()): boolean { + const { AllowStandalone, InstanceUrl } = snapshot; + if (AllowStandalone.source !== 'default') return AllowStandalone.value; + return !InstanceUrl.locked; +} + +/** + * Whether accounts may keep mail on this device: `forced` when the policy says so + * either way, `fallback` for the default where the user hasn't chosen. Forced off + * feeds `noOffline` (lib/device-policy.ts), which also clears what's there already. + */ +export function desktopOfflinePolicy(snapshot: PolicySnapshot = getDesktopPolicy()): { forced: boolean | null; fallback: boolean | null } { + const { OfflineEnabled } = snapshot; + if (OfflineEnabled.locked) return { forced: OfflineEnabled.value, fallback: null }; + return { forced: null, fallback: OfflineEnabled.source === 'default' ? null : OfflineEnabled.value }; +} diff --git a/lib/desktop/server-policy.ts b/lib/desktop/server-policy.ts new file mode 100644 index 000000000..889259fca --- /dev/null +++ b/lib/desktop/server-policy.ts @@ -0,0 +1,42 @@ +/** + * What OS policies say the embedded server has to enforce: which plugins may be + * installed, switched on or served, and which mail servers it may sign in to. + * + * The Electron main process sends this at start and on every change. It lives on + * globalThis, which all of Next's bundles share, like the list of trusted servers + * in trusted-servers.ts. Nothing in the renderer or the environment can write it. + * + * Only the desktop build has it. In every other build these all answer "allowed". + */ + +import { NextResponse } from 'next/server'; +import { IS_DESKTOP_BUILD } from './flag'; +import { jmapServerAllowed, pluginAllowedByPolicy, type ServerPolicy } from './policy-schema'; + +const POLICY_KEY = Symbol.for('bulwark.desktop.serverPolicy'); + +function serverPolicy(): ServerPolicy | undefined { + if (!IS_DESKTOP_BUILD) return undefined; + return (globalThis as unknown as Record)[POLICY_KEY]; +} + +export function pluginRefusedByDevicePolicy(id: unknown): boolean { + const policy = serverPolicy(); + return !!policy && !pluginAllowedByPolicy(policy.pluginsMode, policy.pluginAllowlist, id); +} + +/** A 403 with a code the install route's caller can show, or null when it's allowed. */ +export function devicePolicyPluginRefusal(id: unknown): NextResponse | null { + if (!pluginRefusedByDevicePolicy(id)) return null; + return NextResponse.json({ error: 'Your organisation does not allow this plugin on this device', code: 'blocked_by_policy' }, { status: 403 }); +} + +export function mailServerRefusedByDevicePolicy(serverUrl: unknown): boolean { + const policy = serverPolicy(); + return !!policy && !jmapServerAllowed(policy.allowedJmapServers, serverUrl); +} + +export function devicePolicyServerRefusal(serverUrl: unknown): NextResponse | null { + if (!mailServerRefusedByDevicePolicy(serverUrl)) return null; + return NextResponse.json({ error: 'Your organisation does not allow this server', code: 'blocked_by_policy' }, { status: 403 }); +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index e049b727b..04d9dac4e 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -7,6 +7,8 @@ * `version`; adding a member doesn't. */ +import type { PolicySnapshot } from './policy-schema'; + export const DESKTOP_BRIDGE_VERSION = 1; export type DesktopWindowType = 'main' | 'message' | 'composer' | 'settings'; @@ -58,7 +60,7 @@ export interface DesktopShellBridge { export type DesktopServerPrepareResult = | { ok: true; approved: boolean } - | { ok: false; reason: 'declined' | 'invalid' }; + | { ok: false; reason: 'declined' | 'invalid' | 'policy' }; export interface DesktopServersBridge { /** @@ -111,6 +113,18 @@ export interface DesktopPluginsBridge { install: (slug: string, version?: string) => Promise; } +export interface DesktopPolicyBridge { + /** What's in force now. Synchronous: a page can read it in its first render. */ + get: () => PolicySnapshot; + onChange: (listener: (snapshot: PolicySnapshot) => void) => () => void; + /** + * A forced WipeOfflineData value the app hasn't acted on. Delete local mail in the + * handler: when its promise resolves the value counts as done and never comes back, + * also after a restart. If it rejects, the next launch asks again. + */ + onWipeOfflineData: (handler: (nonce: string) => Promise) => () => void; +} + export interface BulwarkDesktopBridge { version: number; platform: 'win32' | 'darwin' | 'linux'; @@ -120,6 +134,7 @@ export interface BulwarkDesktopBridge { menu?: DesktopMenuBridge; shell?: DesktopShellBridge; lifecycle?: DesktopLifecycleBridge; + policy?: DesktopPolicyBridge; servers?: DesktopServersBridge; auth?: DesktopAuthBridge; plugins?: DesktopPluginsBridge; diff --git a/lib/device-policy.ts b/lib/device-policy.ts index 45bce6358..fc8a0053c 100644 --- a/lib/device-policy.ts +++ b/lib/device-policy.ts @@ -28,6 +28,7 @@ import { DEFAULT_ORGANISATION_POLICY, type OrganisationPolicy } from '@/lib/admin/organisation-policy'; import { setDownloadGuard } from '@/lib/download-guard'; +import { desktopOfflinePolicy } from '@/lib/desktop/policy'; import { subjectForAccount } from '@/lib/org/account-subjects'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; @@ -83,7 +84,9 @@ export function devicesForAccount(subject: DeviceSubject): DevicePolicy { const store = usePolicyStore.getState(); const accountSubject = subjectForAccount(accountId); const policy = accountSubject === store.subject ? store.policy : store.resolveFor(accountSubject); - return devicesOf(policy); + const devices = devicesOf(policy); + // The desktop app's OS policy says the same thing as the organisation's switch. + return desktopOfflinePolicy().forced === false ? { ...devices, noOffline: true } : devices; } /** Local ids of every account signed in here, the active one included. */ diff --git a/lib/oauth/custom-upstream.ts b/lib/oauth/custom-upstream.ts index c8230f36f..24d08396e 100644 --- a/lib/oauth/custom-upstream.ts +++ b/lib/oauth/custom-upstream.ts @@ -18,6 +18,7 @@ import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-server import { decryptPayload, encryptPayload } from '@/lib/auth/crypto'; import { getSignInCookieOptions } from '@/lib/oauth/cookie-config'; import { isPublicHttpUrl } from '@/lib/security/url-guard'; +import { mailServerRefusedByDevicePolicy } from '@/lib/desktop/server-policy'; type CookieStore = Awaited>; @@ -37,6 +38,7 @@ export function customUpstreamCookieName(slot: number): string { /** Null when the address isn't one the sign-in routes would accept right now. */ export async function resolveCustomUpstream(serverUrl: unknown, clientId: unknown): Promise { if (typeof serverUrl !== 'string' || !serverUrl || typeof clientId !== 'string' || !clientId) return null; + if (mailServerRefusedByDevicePolicy(serverUrl)) return null; await configManager.ensureLoaded(); const servers = parseJmapServers(configManager.get('jmapServers', [])); const trusted = resolveTrustedJmapUrl(serverUrl, configManager.get('jmapServerUrl', ''), servers) !== null; diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index cff371fa0..000f2c536 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -39,6 +39,7 @@ import { hookArgsFor } from './hook-payload'; import { rememberDeclaredHooks } from '../plugin-send-guards'; import type { SlotName } from '../plugin-types'; import { PLUGIN_REFUSED_ERROR, pluginAllowed } from '../outgoing-hosts'; +import { PLUGIN_POLICY_REFUSED_ERROR, pluginAllowedByDesktopPolicy } from '../desktop/policy'; // ─── Hook-bus lookup (one flat map for name → bus) ──────────── @@ -229,6 +230,12 @@ export function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { } async function startSandboxedPlugin(plugin: InstalledPlugin): Promise { + // The device's OS policy. On an instance this is the only gate there is: the + // instance's server knows nothing of this device's policy. + if (!pluginAllowedByDesktopPolicy(plugin.id)) { + storeAccessor?.setPluginStatus(plugin.id, 'error', PLUGIN_POLICY_REFUSED_ERROR); + return; + } // A plugin that declares network access doesn't start while third-party // traffic is off; the rest run (#903). if (!pluginAllowed(plugin)) { diff --git a/lib/plugin-send-guards.ts b/lib/plugin-send-guards.ts index 916b4f9e7..f375077f2 100644 --- a/lib/plugin-send-guards.ts +++ b/lib/plugin-send-guards.ts @@ -53,6 +53,7 @@ export interface DownSendGuard { /** Errors that mean Bulwark refused to start the plugin, not that it broke. */ const REFUSALS = new Set([ 'third-party-off', + 'device-policy', 'user-plugins-off', 'approval-unavailable', 'approval-denied', diff --git a/locales/ar/common.json b/locales/ar/common.json index 91db0f8ea..18186078c 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6509,5 +6509,14 @@ "server_needed": "أدخل عنوان الخادم أولًا.", "server_declined": "لم تسمح بهذا الخادم، لذا لم يُرسل إليه أي شيء.", "server_invalid": "عنوان الخادم هذا غير صالح." + }, + "desktop_policy": { + "server_refused": "لا تسمح مؤسستك بهذا الخادم على هذا الجهاز.", + "plugin_refused": "لا تسمح مؤسستك بهذه الإضافة على هذا الجهاز، لذلك لا تعمل. تبقى إعداداتها وبياناتها محفوظة.", + "install_refused": "لا تسمح مؤسستك بهذه الإضافة على هذا الجهاز.", + "plugins_label": "الإضافات", + "plugins_off": "أوقفت مؤسستك الإضافات على هذا الجهاز.", + "plugins_allowlist": "لا تسمح مؤسستك إلا بإضافات معينة على هذا الجهاز.", + "off": "إيقاف" } } diff --git a/locales/ca/common.json b/locales/ca/common.json index d2e062a69..ee7e56879 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Primer escriu l'adreça del servidor.", "server_declined": "No has permès aquest servidor, així que no s'hi ha enviat res.", "server_invalid": "Aquesta adreça de servidor no és vàlida." + }, + "desktop_policy": { + "server_refused": "La teva organització no permet aquest servidor en aquest dispositiu.", + "plugin_refused": "La teva organització no permet aquest connector en aquest dispositiu, per tant no s'executa. Se'n conserven la configuració i les dades.", + "install_refused": "La teva organització no permet aquest connector en aquest dispositiu.", + "plugins_label": "Connectors", + "plugins_off": "La teva organització ha desactivat els connectors en aquest dispositiu.", + "plugins_allowlist": "La teva organització només permet determinats connectors en aquest dispositiu.", + "off": "Desactivat" } } diff --git a/locales/cs/common.json b/locales/cs/common.json index 6b1e41754..4382cc1c9 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Nejdřív zadejte adresu serveru.", "server_declined": "Tento server jste nepovolili, proto se na něj nic neodeslalo.", "server_invalid": "Tato adresa serveru není platná." + }, + "desktop_policy": { + "server_refused": "Vaše organizace tento server na tomto zařízení nepovoluje.", + "plugin_refused": "Vaše organizace tento plugin na tomto zařízení nepovoluje, proto neběží. Jeho nastavení a data zůstávají zachována.", + "install_refused": "Vaše organizace tento plugin na tomto zařízení nepovoluje.", + "plugins_label": "Pluginy", + "plugins_off": "Vaše organizace na tomto zařízení vypnula pluginy.", + "plugins_allowlist": "Vaše organizace na tomto zařízení povoluje jen některé pluginy.", + "off": "Vypnuto" } } diff --git a/locales/da/common.json b/locales/da/common.json index 61d6a1412..20f982ea6 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Angiv først serverens adresse.", "server_declined": "Du tillod ikke denne server, så der blev ikke sendt noget til den.", "server_invalid": "Denne serveradresse er ikke gyldig." + }, + "desktop_policy": { + "server_refused": "Din organisation tillader ikke denne server på denne enhed.", + "plugin_refused": "Din organisation tillader ikke dette plugin på denne enhed, så det kører ikke. Dets indstillinger og data bevares.", + "install_refused": "Din organisation tillader ikke dette plugin på denne enhed.", + "plugins_label": "Plugins", + "plugins_off": "Din organisation har slået plugins fra på denne enhed.", + "plugins_allowlist": "Din organisation tillader kun bestemte plugins på denne enhed.", + "off": "Fra" } } diff --git a/locales/de/common.json b/locales/de/common.json index eb1ec9a29..ee41c50c2 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Geben Sie zuerst die Adresse Ihres Servers ein.", "server_declined": "Sie haben diesen Server nicht zugelassen, daher wurde nichts an ihn gesendet.", "server_invalid": "Diese Serveradresse ist ungültig." + }, + "desktop_policy": { + "server_refused": "Ihre Organisation erlaubt diesen Server auf diesem Gerät nicht.", + "plugin_refused": "Ihre Organisation erlaubt dieses Plugin auf diesem Gerät nicht, daher läuft es nicht. Seine Einstellungen und Daten bleiben erhalten.", + "install_refused": "Ihre Organisation erlaubt dieses Plugin auf diesem Gerät nicht.", + "plugins_label": "Plugins", + "plugins_off": "Ihre Organisation hat Plugins auf diesem Gerät ausgeschaltet.", + "plugins_allowlist": "Ihre Organisation erlaubt auf diesem Gerät nur bestimmte Plugins.", + "off": "Aus" } } diff --git a/locales/en/common.json b/locales/en/common.json index 0af0e143f..8a7e84a1a 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Enter your server's address first.", "server_declined": "You didn't allow this server, so nothing was sent to it.", "server_invalid": "This server address isn't valid." + }, + "desktop_policy": { + "server_refused": "Your organisation doesn't allow this server on this device.", + "plugin_refused": "Your organisation doesn't allow this plugin on this device, so it doesn't run. Its settings and data are kept.", + "install_refused": "Your organisation doesn't allow this plugin on this device.", + "plugins_label": "Plugins", + "plugins_off": "Your organisation turned off plugins on this device.", + "plugins_allowlist": "Your organisation only allows certain plugins on this device.", + "off": "Off" } } diff --git a/locales/es/common.json b/locales/es/common.json index 83aeead77..cf1ededd5 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Primero escribe la dirección del servidor.", "server_declined": "No permitiste este servidor, así que no se le envió nada.", "server_invalid": "Esta dirección de servidor no es válida." + }, + "desktop_policy": { + "server_refused": "Tu organización no permite este servidor en este dispositivo.", + "plugin_refused": "Tu organización no permite este complemento en este dispositivo, por lo que no se ejecuta. Se conservan sus ajustes y datos.", + "install_refused": "Tu organización no permite este complemento en este dispositivo.", + "plugins_label": "Complementos", + "plugins_off": "Tu organización ha desactivado los complementos en este dispositivo.", + "plugins_allowlist": "Tu organización solo permite determinados complementos en este dispositivo.", + "off": "Desactivado" } } diff --git a/locales/et/common.json b/locales/et/common.json index 788fb5171..ac23f61fa 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Sisesta kõigepealt serveri aadress.", "server_declined": "Sa ei lubanud seda serverit, nii et sellele ei saadetud midagi.", "server_invalid": "See serveri aadress ei kehti." + }, + "desktop_policy": { + "server_refused": "Sinu organisatsioon ei luba seda serverit selles seadmes.", + "plugin_refused": "Sinu organisatsioon ei luba seda pistikprogrammi selles seadmes, seega see ei tööta. Selle seaded ja andmed säilivad.", + "install_refused": "Sinu organisatsioon ei luba seda pistikprogrammi selles seadmes.", + "plugins_label": "Pistikprogrammid", + "plugins_off": "Sinu organisatsioon on selles seadmes pistikprogrammid välja lülitanud.", + "plugins_allowlist": "Sinu organisatsioon lubab selles seadmes ainult teatud pistikprogramme.", + "off": "Väljas" } } diff --git a/locales/fa/common.json b/locales/fa/common.json index 9eadf3199..6584235ae 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6509,5 +6509,14 @@ "server_needed": "ابتدا نشانی سرور را وارد کنید.", "server_declined": "شما این سرور را مجاز نکردید، پس چیزی به آن فرستاده نشد.", "server_invalid": "این نشانی سرور معتبر نیست." + }, + "desktop_policy": { + "server_refused": "سازمان شما این سرور را در این دستگاه مجاز نمی‌داند.", + "plugin_refused": "سازمان شما این افزونه را در این دستگاه مجاز نمی‌داند، بنابراین اجرا نمی‌شود. تنظیمات و داده‌های آن حفظ می‌شوند.", + "install_refused": "سازمان شما این افزونه را در این دستگاه مجاز نمی‌داند.", + "plugins_label": "افزونه‌ها", + "plugins_off": "سازمان شما افزونه‌ها را در این دستگاه خاموش کرده است.", + "plugins_allowlist": "سازمان شما در این دستگاه فقط برخی افزونه‌ها را مجاز می‌داند.", + "off": "خاموش" } } diff --git a/locales/fr/common.json b/locales/fr/common.json index a44af9dbe..e866dd9f7 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Saisissez d'abord l'adresse du serveur.", "server_declined": "Vous n'avez pas autorisé ce serveur, donc rien ne lui a été envoyé.", "server_invalid": "Cette adresse de serveur n'est pas valide." + }, + "desktop_policy": { + "server_refused": "Votre organisation n'autorise pas ce serveur sur cet appareil.", + "plugin_refused": "Votre organisation n'autorise pas ce plugin sur cet appareil, il ne s'exécute donc pas. Ses réglages et ses données sont conservés.", + "install_refused": "Votre organisation n'autorise pas ce plugin sur cet appareil.", + "plugins_label": "Plugins", + "plugins_off": "Votre organisation a désactivé les plugins sur cet appareil.", + "plugins_allowlist": "Votre organisation n'autorise que certains plugins sur cet appareil.", + "off": "Désactivé" } } diff --git a/locales/he/common.json b/locales/he/common.json index 17580b9a8..71078b679 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6510,5 +6510,14 @@ "server_needed": "קודם צריך להזין את כתובת השרת.", "server_declined": "לא אישרת את השרת הזה, לכן לא נשלח אליו דבר.", "server_invalid": "כתובת השרת הזו לא תקינה." + }, + "desktop_policy": { + "server_refused": "הארגון שלך לא מאפשר את השרת הזה במכשיר הזה.", + "plugin_refused": "הארגון שלך לא מאפשר את התוסף הזה במכשיר הזה, ולכן הוא לא פועל. ההגדרות והנתונים שלו נשמרים.", + "install_refused": "הארגון שלך לא מאפשר את התוסף הזה במכשיר הזה.", + "plugins_label": "תוספים", + "plugins_off": "הארגון שלך כיבה תוספים במכשיר הזה.", + "plugins_allowlist": "הארגון שלך מאפשר במכשיר הזה רק תוספים מסוימים.", + "off": "כבוי" } } diff --git a/locales/hu/common.json b/locales/hu/common.json index 474772591..7ac18ad01 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Először adja meg a szerver címét.", "server_declined": "Nem engedélyezte ezt a szervert, ezért semmit sem küldtünk neki.", "server_invalid": "Ez a szervercím nem érvényes." + }, + "desktop_policy": { + "server_refused": "A szervezete ezen az eszközön nem engedélyezi ezt a kiszolgálót.", + "plugin_refused": "A szervezete ezen az eszközön nem engedélyezi ezt a bővítményt, ezért nem fut. A beállításai és adatai megmaradnak.", + "install_refused": "A szervezete ezen az eszközön nem engedélyezi ezt a bővítményt.", + "plugins_label": "Bővítmények", + "plugins_off": "A szervezete ezen az eszközön kikapcsolta a bővítményeket.", + "plugins_allowlist": "A szervezete ezen az eszközön csak bizonyos bővítményeket engedélyez.", + "off": "Ki" } } diff --git a/locales/it/common.json b/locales/it/common.json index 8cc39c364..e8f98ec56 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Inserisci prima l'indirizzo del server.", "server_declined": "Non hai autorizzato questo server, quindi non gli è stato inviato nulla.", "server_invalid": "Questo indirizzo del server non è valido." + }, + "desktop_policy": { + "server_refused": "La tua organizzazione non consente questo server su questo dispositivo.", + "plugin_refused": "La tua organizzazione non consente questo plugin su questo dispositivo, quindi non viene eseguito. Le sue impostazioni e i suoi dati restano conservati.", + "install_refused": "La tua organizzazione non consente questo plugin su questo dispositivo.", + "plugins_label": "Plugin", + "plugins_off": "La tua organizzazione ha disattivato i plugin su questo dispositivo.", + "plugins_allowlist": "La tua organizzazione consente solo alcuni plugin su questo dispositivo.", + "off": "Disattivato" } } diff --git a/locales/ja/common.json b/locales/ja/common.json index e051ceb66..b0e90e037 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6509,5 +6509,14 @@ "server_needed": "先にサーバーのアドレスを入力してください。", "server_declined": "このサーバーを許可しなかったため、何も送信していません。", "server_invalid": "このサーバーアドレスは正しくありません。" + }, + "desktop_policy": { + "server_refused": "組織はこのデバイスでこのサーバーを許可していません。", + "plugin_refused": "組織はこのデバイスでこのプラグインを許可していないため、実行されません。設定とデータは保持されます。", + "install_refused": "組織はこのデバイスでこのプラグインを許可していません。", + "plugins_label": "プラグイン", + "plugins_off": "組織はこのデバイスのプラグインをオフにしています。", + "plugins_allowlist": "組織はこのデバイスで特定のプラグインのみを許可しています。", + "off": "オフ" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index e7f7be907..97e94a1f0 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6509,5 +6509,14 @@ "server_needed": "먼저 서버 주소를 입력하세요.", "server_declined": "이 서버를 허용하지 않아 아무것도 보내지 않았습니다.", "server_invalid": "이 서버 주소는 올바르지 않습니다." + }, + "desktop_policy": { + "server_refused": "조직에서 이 기기의 이 서버 사용을 허용하지 않습니다.", + "plugin_refused": "조직에서 이 기기의 이 플러그인 사용을 허용하지 않아 실행되지 않습니다. 설정과 데이터는 유지됩니다.", + "install_refused": "조직에서 이 기기의 이 플러그인 사용을 허용하지 않습니다.", + "plugins_label": "플러그인", + "plugins_off": "조직에서 이 기기의 플러그인을 껐습니다.", + "plugins_allowlist": "조직에서 이 기기에 특정 플러그인만 허용합니다.", + "off": "끔" } } diff --git a/locales/lv/common.json b/locales/lv/common.json index c07fe9c31..2e231040a 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Vispirms ievadiet servera adresi.", "server_declined": "Jūs neatļāvāt šo serveri, tāpēc tam nekas netika nosūtīts.", "server_invalid": "Šī servera adrese nav derīga." + }, + "desktop_policy": { + "server_refused": "Jūsu organizācija šajā ierīcē neatļauj šo serveri.", + "plugin_refused": "Jūsu organizācija šajā ierīcē neatļauj šo spraudni, tāpēc tas nedarbojas. Tā iestatījumi un dati tiek saglabāti.", + "install_refused": "Jūsu organizācija šajā ierīcē neatļauj šo spraudni.", + "plugins_label": "Spraudņi", + "plugins_off": "Jūsu organizācija šajā ierīcē ir izslēgusi spraudņus.", + "plugins_allowlist": "Jūsu organizācija šajā ierīcē atļauj tikai noteiktus spraudņus.", + "off": "Izslēgts" } } diff --git a/locales/mn/common.json b/locales/mn/common.json index 4633a5d6d..5ae09dff5 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Эхлээд серверийн хаягаа оруулна уу.", "server_declined": "Та энэ серверийг зөвшөөрөөгүй тул юу ч илгээгээгүй.", "server_invalid": "Энэ серверийн хаяг буруу байна." + }, + "desktop_policy": { + "server_refused": "Танай байгууллага энэ төхөөрөмж дээр энэ серверийг зөвшөөрөхгүй байна.", + "plugin_refused": "Танай байгууллага энэ төхөөрөмж дээр энэ залгаасыг зөвшөөрөхгүй тул ажиллахгүй. Тохиргоо, өгөгдөл нь хадгалагдана.", + "install_refused": "Танай байгууллага энэ төхөөрөмж дээр энэ залгаасыг зөвшөөрөхгүй байна.", + "plugins_label": "Залгаасууд", + "plugins_off": "Танай байгууллага энэ төхөөрөмж дээр залгаасуудыг унтраасан.", + "plugins_allowlist": "Танай байгууллага энэ төхөөрөмж дээр зөвхөн тодорхой залгаасуудыг зөвшөөрдөг.", + "off": "Унтраалттай" } } diff --git a/locales/nb/common.json b/locales/nb/common.json index d2e1a518e..1bc660470 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Skriv inn serveradressen først.", "server_declined": "Du tillot ikke denne serveren, så ingenting ble sendt til den.", "server_invalid": "Denne serveradressen er ikke gyldig." + }, + "desktop_policy": { + "server_refused": "Organisasjonen din tillater ikke denne serveren på denne enheten.", + "plugin_refused": "Organisasjonen din tillater ikke dette programtillegget på denne enheten, så det kjører ikke. Innstillingene og dataene beholdes.", + "install_refused": "Organisasjonen din tillater ikke dette programtillegget på denne enheten.", + "plugins_label": "Programtillegg", + "plugins_off": "Organisasjonen din har slått av programtillegg på denne enheten.", + "plugins_allowlist": "Organisasjonen din tillater bare bestemte programtillegg på denne enheten.", + "off": "Av" } } diff --git a/locales/nl/common.json b/locales/nl/common.json index b704a66fc..574fbdd85 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Vul eerst het adres van de server in.", "server_declined": "Je hebt deze server niet toegestaan, dus er is niets naartoe gestuurd.", "server_invalid": "Dit serveradres is niet geldig." + }, + "desktop_policy": { + "server_refused": "Je organisatie staat deze server niet toe op dit apparaat.", + "plugin_refused": "Je organisatie staat deze plug-in niet toe op dit apparaat, dus hij draait niet. De instellingen en gegevens blijven bewaard.", + "install_refused": "Je organisatie staat deze plug-in niet toe op dit apparaat.", + "plugins_label": "Plug-ins", + "plugins_off": "Je organisatie heeft plug-ins op dit apparaat uitgeschakeld.", + "plugins_allowlist": "Je organisatie staat op dit apparaat alleen bepaalde plug-ins toe.", + "off": "Uit" } } diff --git a/locales/pl/common.json b/locales/pl/common.json index 8ccc30784..0cdfaf4f5 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Najpierw wpisz adres serwera.", "server_declined": "Ten serwer nie został dozwolony, więc nic do niego nie wysłano.", "server_invalid": "Ten adres serwera jest nieprawidłowy." + }, + "desktop_policy": { + "server_refused": "Twoja organizacja nie zezwala na ten serwer na tym urządzeniu.", + "plugin_refused": "Twoja organizacja nie zezwala na tę wtyczkę na tym urządzeniu, więc nie działa. Jej ustawienia i dane zostają zachowane.", + "install_refused": "Twoja organizacja nie zezwala na tę wtyczkę na tym urządzeniu.", + "plugins_label": "Wtyczki", + "plugins_off": "Twoja organizacja wyłączyła wtyczki na tym urządzeniu.", + "plugins_allowlist": "Twoja organizacja zezwala na tym urządzeniu tylko na wybrane wtyczki.", + "off": "Wyłączone" } } diff --git a/locales/pt/common.json b/locales/pt/common.json index 188b256f8..c6370becb 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Digite primeiro o endereço do servidor.", "server_declined": "Você não permitiu este servidor, então nada foi enviado a ele.", "server_invalid": "Este endereço de servidor não é válido." + }, + "desktop_policy": { + "server_refused": "A sua organização não permite este servidor neste dispositivo.", + "plugin_refused": "A sua organização não permite este plugin neste dispositivo, por isso não é executado. As suas definições e dados são mantidos.", + "install_refused": "A sua organização não permite este plugin neste dispositivo.", + "plugins_label": "Plugins", + "plugins_off": "A sua organização desativou os plugins neste dispositivo.", + "plugins_allowlist": "A sua organização só permite determinados plugins neste dispositivo.", + "off": "Desativado" } } diff --git a/locales/ro/common.json b/locales/ro/common.json index ae31839c2..eb8e602c5 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Introdu mai întâi adresa serverului.", "server_declined": "Nu ai permis acest server, așa că nu i s-a trimis nimic.", "server_invalid": "Această adresă de server nu este validă." + }, + "desktop_policy": { + "server_refused": "Organizația ta nu permite acest server pe acest dispozitiv.", + "plugin_refused": "Organizația ta nu permite acest plugin pe acest dispozitiv, așa că nu rulează. Setările și datele sale sunt păstrate.", + "install_refused": "Organizația ta nu permite acest plugin pe acest dispozitiv.", + "plugins_label": "Pluginuri", + "plugins_off": "Organizația ta a dezactivat pluginurile pe acest dispozitiv.", + "plugins_allowlist": "Organizația ta permite doar anumite pluginuri pe acest dispozitiv.", + "off": "Dezactivat" } } diff --git a/locales/ru/common.json b/locales/ru/common.json index a9ee1c65c..033c3f115 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Сначала введите адрес сервера.", "server_declined": "Вы не разрешили этот сервер, поэтому ему ничего не отправлено.", "server_invalid": "Этот адрес сервера недействителен." + }, + "desktop_policy": { + "server_refused": "Ваша организация не разрешает этот сервер на этом устройстве.", + "plugin_refused": "Ваша организация не разрешает этот плагин на этом устройстве, поэтому он не работает. Его настройки и данные сохраняются.", + "install_refused": "Ваша организация не разрешает этот плагин на этом устройстве.", + "plugins_label": "Плагины", + "plugins_off": "Ваша организация отключила плагины на этом устройстве.", + "plugins_allowlist": "Ваша организация разрешает на этом устройстве только некоторые плагины.", + "off": "Выкл." } } diff --git a/locales/sk/common.json b/locales/sk/common.json index a018ba55a..47d70b0c2 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Najprv zadajte adresu servera.", "server_declined": "Tento server ste nepovolili, preto sa naň nič neodoslalo.", "server_invalid": "Táto adresa servera nie je platná." + }, + "desktop_policy": { + "server_refused": "Vaša organizácia tento server na tomto zariadení nepovoľuje.", + "plugin_refused": "Vaša organizácia tento plugin na tomto zariadení nepovoľuje, preto nebeží. Jeho nastavenia a údaje zostávajú zachované.", + "install_refused": "Vaša organizácia tento plugin na tomto zariadení nepovoľuje.", + "plugins_label": "Pluginy", + "plugins_off": "Vaša organizácia na tomto zariadení vypla pluginy.", + "plugins_allowlist": "Vaša organizácia na tomto zariadení povoľuje len niektoré pluginy.", + "off": "Vypnuté" } } diff --git a/locales/th/common.json b/locales/th/common.json index 065a7afbc..9afe67f92 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6517,5 +6517,14 @@ "server_needed": "ป้อนที่อยู่เซิร์ฟเวอร์ก่อน", "server_declined": "คุณไม่ได้อนุญาตเซิร์ฟเวอร์นี้ จึงไม่มีการส่งข้อมูลใดไป", "server_invalid": "ที่อยู่เซิร์ฟเวอร์นี้ไม่ถูกต้อง" + }, + "desktop_policy": { + "server_refused": "องค์กรของคุณไม่อนุญาตให้ใช้เซิร์ฟเวอร์นี้บนอุปกรณ์นี้", + "plugin_refused": "องค์กรของคุณไม่อนุญาตให้ใช้ปลั๊กอินนี้บนอุปกรณ์นี้ จึงไม่ทำงาน แต่การตั้งค่าและข้อมูลของปลั๊กอินยังคงอยู่", + "install_refused": "องค์กรของคุณไม่อนุญาตให้ใช้ปลั๊กอินนี้บนอุปกรณ์นี้", + "plugins_label": "ปลั๊กอิน", + "plugins_off": "องค์กรของคุณปิดปลั๊กอินบนอุปกรณ์นี้", + "plugins_allowlist": "องค์กรของคุณอนุญาตเฉพาะปลั๊กอินบางตัวบนอุปกรณ์นี้", + "off": "ปิด" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index 81acedbf7..0bddd6f29 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Önce sunucu adresini girin.", "server_declined": "Bu sunucuya izin vermediniz, bu yüzden ona hiçbir şey gönderilmedi.", "server_invalid": "Bu sunucu adresi geçerli değil." + }, + "desktop_policy": { + "server_refused": "Kuruluşunuz bu cihazda bu sunucuya izin vermiyor.", + "plugin_refused": "Kuruluşunuz bu cihazda bu eklentiye izin vermiyor, bu yüzden çalışmıyor. Ayarları ve verileri korunur.", + "install_refused": "Kuruluşunuz bu cihazda bu eklentiye izin vermiyor.", + "plugins_label": "Eklentiler", + "plugins_off": "Kuruluşunuz bu cihazda eklentileri kapattı.", + "plugins_allowlist": "Kuruluşunuz bu cihazda yalnızca belirli eklentilere izin veriyor.", + "off": "Kapalı" } } diff --git a/locales/uk/common.json b/locales/uk/common.json index 42dbd1aba..6e534d0ff 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6509,5 +6509,14 @@ "server_needed": "Спочатку введіть адресу сервера.", "server_declined": "Ви не дозволили цей сервер, тому йому нічого не надіслано.", "server_invalid": "Ця адреса сервера недійсна." + }, + "desktop_policy": { + "server_refused": "Ваша організація не дозволяє цей сервер на цьому пристрої.", + "plugin_refused": "Ваша організація не дозволяє цей плагін на цьому пристрої, тому він не працює. Його налаштування та дані зберігаються.", + "install_refused": "Ваша організація не дозволяє цей плагін на цьому пристрої.", + "plugins_label": "Плагіни", + "plugins_off": "Ваша організація вимкнула плагіни на цьому пристрої.", + "plugins_allowlist": "Ваша організація дозволяє на цьому пристрої лише деякі плагіни.", + "off": "Вимк." } } diff --git a/locales/vi/common.json b/locales/vi/common.json index 9cbcd8bd3..9af8a1e43 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6521,5 +6521,14 @@ "server_needed": "Trước tiên hãy nhập địa chỉ máy chủ.", "server_declined": "Bạn không cho phép máy chủ này nên không có gì được gửi đến đó.", "server_invalid": "Địa chỉ máy chủ này không hợp lệ." + }, + "desktop_policy": { + "server_refused": "Tổ chức của bạn không cho phép máy chủ này trên thiết bị này.", + "plugin_refused": "Tổ chức của bạn không cho phép tiện ích này trên thiết bị này nên tiện ích không chạy. Cài đặt và dữ liệu của tiện ích vẫn được giữ lại.", + "install_refused": "Tổ chức của bạn không cho phép tiện ích này trên thiết bị này.", + "plugins_label": "Tiện ích", + "plugins_off": "Tổ chức của bạn đã tắt tiện ích trên thiết bị này.", + "plugins_allowlist": "Tổ chức của bạn chỉ cho phép một số tiện ích trên thiết bị này.", + "off": "Tắt" } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 5ba44316f..1e1a1f55e 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6509,5 +6509,14 @@ "server_needed": "請先輸入伺服器位址。", "server_declined": "你沒有允許此伺服器,因此沒有傳送任何內容給它。", "server_invalid": "此伺服器位址無效。" + }, + "desktop_policy": { + "server_refused": "您的組織不允許在此裝置上使用此伺服器。", + "plugin_refused": "您的組織不允許在此裝置上使用此外掛程式,因此不會執行。其設定和資料會保留。", + "install_refused": "您的組織不允許在此裝置上使用此外掛程式。", + "plugins_label": "外掛程式", + "plugins_off": "您的組織已在此裝置上關閉外掛程式。", + "plugins_allowlist": "您的組織在此裝置上只允許使用部分外掛程式。", + "off": "關閉" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index f53c82da4..c64c5540c 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6509,5 +6509,14 @@ "server_needed": "请先输入服务器地址。", "server_declined": "你没有允许此服务器,因此没有向它发送任何内容。", "server_invalid": "此服务器地址无效。" + }, + "desktop_policy": { + "server_refused": "您的组织不允许在此设备上使用此服务器。", + "plugin_refused": "您的组织不允许在此设备上使用此插件,因此它不会运行。其设置和数据会保留。", + "install_refused": "您的组织不允许在此设备上使用此插件。", + "plugins_label": "插件", + "plugins_off": "您的组织已在此设备上关闭插件。", + "plugins_allowlist": "您的组织在此设备上只允许使用部分插件。", + "off": "关闭" } } diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 84572efff..f197e8dc2 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -27,6 +27,7 @@ import { fetchServerPluginList } from '@/lib/server-plugin-list'; import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types'; import type { Permission } from '@/lib/plugin-types'; import { IS_LITE } from '@/lib/lite'; +import { PLUGIN_POLICY_REFUSED_ERROR, onDesktopPolicyChange, pluginAllowedByDesktopPolicy } from '@/lib/desktop/policy'; import { onFullSignOut } from '@/lib/sign-out-cleanup'; import { forgetDeclaredHooks, setSendGuardSource } from '@/lib/plugin-send-guards'; @@ -42,7 +43,7 @@ let regionalSubscribed = false; */ export interface InstallResult { success: boolean; - reason?: 'invalid' | 'uploads-disabled' | 'managed-id'; + reason?: 'invalid' | 'uploads-disabled' | 'managed-id' | 'device-policy'; error?: string; warnings?: string[]; approvalStatus?: InstalledPlugin['approvalStatus']; @@ -152,6 +153,9 @@ export const usePluginStore = create()( } const { manifest, code } = result; + if (!pluginAllowedByDesktopPolicy(manifest.id)) { + return { success: false, reason: 'device-policy' }; + } const { plugins } = get(); // Check for duplicate @@ -234,6 +238,11 @@ export const usePluginStore = create()( const plugin = plugins.find(p => p.id === id); if (!plugin) return; + if (!pluginAllowedByDesktopPolicy(id)) { + set(state => ({ plugins: state.plugins.map(p => (p.id === id ? { ...p, status: 'error', error: PLUGIN_POLICY_REFUSED_ERROR } : p)) })); + return; + } + // Admin approval gate for plugins the user installed. The server // holds the decision, pinned to the package hash (code + manifest). if (!plugin.managed && !(await userPluginMayRun(plugin, get, set, { request: true }))) { @@ -489,6 +498,8 @@ interface ServerPluginInfo { updatedAt?: string; /** True when the plugin was loaded from the server's PLUGIN_DEV_DIR */ dev?: boolean; + /** The device's OS policy refuses it. Its data stays, and it doesn't run. */ + refused?: boolean; /** Allowlist of origins this plugin may target via api.http.fetch(). */ httpOrigins?: string[]; /** Allowlist of same-origin /api/* paths this plugin may target via api.http.post(). */ @@ -614,6 +625,10 @@ async function syncServerPlugins( const outcomes = await Promise.all(serverPlugins.map(async (sp) => { const local = get().plugins.find(p => p.id === sp.id); + // Refused by the device's policy: fetch nothing and change nothing. It stays in + // serverPluginIds below, so it isn't taken for removed and its data stays. + if (sp.refused || !pluginAllowedByDesktopPolicy(sp.id)) return { sp, isNew: false, needsBundle: false, refused: true }; + if (!local) { // New server plugin - download bundle and install. const code = await downloadPluginBundle(sp.id, sp.bundleHash, sp.tier); @@ -651,7 +666,7 @@ async function syncServerPlugins( })); for (const outcome of outcomes) { - if (!outcome) continue; + if (!outcome || outcome.refused) continue; const { sp, isNew, needsBundle } = outcome; const local = get().plugins.find(p => p.id === sp.id); @@ -822,6 +837,23 @@ async function userPluginMayRun( return false; } +// The device's OS policy changed while the app runs: what it now refuses stops, with +// its record and data left alone, and what it allows again starts as the user left it. +function applyDesktopPluginPolicy(): void { + const { plugins, setPluginStatus } = usePluginStore.getState(); + for (const plugin of plugins) { + const allowed = pluginAllowedByDesktopPolicy(plugin.id); + if (!allowed && getActivePlugin(plugin.id)) { + deactivatePlugin(plugin.id); + setPluginStatus(plugin.id, 'error', PLUGIN_POLICY_REFUSED_ERROR); + } else if (allowed && plugin.error === PLUGIN_POLICY_REFUSED_ERROR) { + setPluginStatus(plugin.id, plugin.enabled ? 'enabled' : 'installed'); + if (plugin.enabled) void loadPlugin(plugin); + } + } +} +onDesktopPolicyChange(applyDesktopPluginPolicy); + // Whatever changed a plugin's settings (Settings, a server sync, an // import), the running plugin hears it right away, and slots it opens later // start with the new values: they are built from the running plugin's record. From 84c73ab15a0938681001bf896f5d4786c17f8227 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:19:28 +0100 Subject: [PATCH 19/35] test: let the store's own sync finish before failing the outbox lookup With contacts and files in the store a pass runs longer, and the failure meant for the lookup could go to the sync's own Email/query. --- lib/offline/__tests__/outbox.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/offline/__tests__/outbox.test.ts b/lib/offline/__tests__/outbox.test.ts index 03e61e04b..f89c609e4 100644 --- a/lib/offline/__tests__/outbox.test.ts +++ b/lib/offline/__tests__/outbox.test.ts @@ -334,6 +334,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(); From fcf31df928fca1660a668da0f563502393d23022 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:45:45 +0100 Subject: [PATCH 20/35] fix: keep one policy object per change so the login page renders in the desktop app --- lib/__tests__/plugin-device-policy.test.ts | 2 +- lib/__tests__/plugin-sandbox-loader.test.ts | 6 +++--- lib/desktop/__tests__/policy-client.test.tsx | 13 ++++++++++++ lib/desktop/policy.ts | 21 +++++++++++++++++++- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/lib/__tests__/plugin-device-policy.test.ts b/lib/__tests__/plugin-device-policy.test.ts index 63c0a0ded..815c56a3f 100644 --- a/lib/__tests__/plugin-device-policy.test.ts +++ b/lib/__tests__/plugin-device-policy.test.ts @@ -97,7 +97,7 @@ function serve(plugins: unknown[]) { beforeEach(() => { vi.clearAllMocks(); usePluginStore.setState({ plugins: [], initialized: false }); - harness.current = snapshotOf({}); + setPolicy({}); localStorage.clear(); usePluginStore.setState({ plugins: [], initialized: false }); }); diff --git a/lib/__tests__/plugin-sandbox-loader.test.ts b/lib/__tests__/plugin-sandbox-loader.test.ts index 4614e23e3..a09c8511d 100644 --- a/lib/__tests__/plugin-sandbox-loader.test.ts +++ b/lib/__tests__/plugin-sandbox-loader.test.ts @@ -106,8 +106,8 @@ describe('loadSandboxedPlugin under the OS policy of the device', () => { it('starts nothing the policy refuses: no frame, no download', async () => { const { EMPTY_POLICY_LAYERS, resolvePolicies } = await import('../desktop/policy-schema'); const snapshot = (forcedMachine: Record) => resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine }); - let current = snapshot({ PluginsMode: 'allowlist', PluginAllowlist: ['other-plugin'] }); - window.bulwarkDesktop = { version: 1, platform: 'win32', policy: { get: () => current, onChange: () => () => {}, onWipeOfflineData: () => () => {} } }; + const bridgeWith = (current: ReturnType) => ({ version: 1, platform: 'win32' as const, policy: { get: () => current, onChange: () => () => {}, onWipeOfflineData: () => () => {} } }); + window.bulwarkDesktop = bridgeWith(snapshot({ PluginsMode: 'allowlist', PluginAllowlist: ['other-plugin'] })); mocks.getCode.mockResolvedValue(CODE); try { await loadSandboxedPlugin(plugin()); @@ -116,7 +116,7 @@ describe('loadSandboxedPlugin under the OS policy of the device', () => { expect(mocks.downloadManagedBundle).not.toHaveBeenCalled(); expect(mocks.getCode).not.toHaveBeenCalled(); - current = snapshot({ PluginsMode: 'allowlist', PluginAllowlist: ['managed-plugin'] }); + window.bulwarkDesktop = bridgeWith(snapshot({ PluginsMode: 'allowlist', PluginAllowlist: ['managed-plugin'] })); await loadSandboxedPlugin(plugin()); expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'running' }); } finally { diff --git a/lib/desktop/__tests__/policy-client.test.tsx b/lib/desktop/__tests__/policy-client.test.tsx index 6d81f29d5..dd35e920d 100644 --- a/lib/desktop/__tests__/policy-client.test.tsx +++ b/lib/desktop/__tests__/policy-client.test.tsx @@ -85,6 +85,19 @@ describe('in the app', () => { expect(devicesForAccount('some-account').noOffline).toBe(false); }, 60_000); + it('the hook settles on a bridge that copies what it returns, as contextBridge does', async () => { + setBridgePolicy(snapshotOf({ PluginsMode: 'off' })); + window.bulwarkDesktop!.policy!.get = () => JSON.parse(JSON.stringify(current)); + let renders = 0; + function Probe() { + renders += 1; + return {useDesktopPolicy().PluginsMode.value}; + } + render(); + expect(screen.getByText('off')).toBeInTheDocument(); + expect(renders).toBeLessThan(4); + }); + it('the hook shows a change at once', async () => { setBridgePolicy(snapshotOf({})); function Probe() { diff --git a/lib/desktop/policy.ts b/lib/desktop/policy.ts index 63b1d4f72..3c3fad5a3 100644 --- a/lib/desktop/policy.ts +++ b/lib/desktop/policy.ts @@ -8,6 +8,7 @@ import { useSyncExternalStore } from 'react'; import { getDesktopBridge } from './bridge'; +import type { DesktopPolicyBridge } from './types'; import { DEFAULT_POLICY_SNAPSHOT, effectivePolicyValue, @@ -21,11 +22,29 @@ import { /** A plugin the device's policy refuses, on a plugin's record and in the settings page. */ export const PLUGIN_POLICY_REFUSED_ERROR = 'device-policy'; +let cached: { bridge: DesktopPolicyBridge; snapshot: PolicySnapshot } | null = null; + +/** + * The same object until the policy changes. The bridge can't give that: contextBridge + * copies what it returns, so each `get()` is a new object, and useSyncExternalStore takes + * a new object for a change and renders forever. + */ export function getDesktopPolicy(): PolicySnapshot { - return getDesktopBridge()?.policy?.get?.() ?? DEFAULT_POLICY_SNAPSHOT; + const bridge = getDesktopBridge()?.policy; + if (!bridge?.get) return DEFAULT_POLICY_SNAPSHOT; + if (cached?.bridge !== bridge) { + const entry = { bridge, snapshot: bridge.get() }; + cached = entry; + bridge.onChange?.((snapshot) => { + entry.snapshot = snapshot; + }); + } + return cached.snapshot; } export function onDesktopPolicyChange(listener: (snapshot: PolicySnapshot) => void): () => void { + // First, so what the listener reads is already the new value. + getDesktopPolicy(); return getDesktopBridge()?.policy?.onChange?.(listener) ?? (() => {}); } From 4044351611fc3f7652b788dd03e12ccaa7daa71a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:51:36 +0100 Subject: [PATCH 21/35] test: keep what was chosen for a store that waits for a new sign-in --- lib/__tests__/sign-out-cleanup.test.ts | 2 +- lib/offline/__tests__/storage-health.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/sign-out-cleanup.test.ts b/lib/__tests__/sign-out-cleanup.test.ts index 005865d76..e0071adfb 100644 --- a/lib/__tests__/sign-out-cleanup.test.ts +++ b/lib/__tests__/sign-out-cleanup.test.ts @@ -26,7 +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-choices', 'bulwark-offline-health', + 'bulwark-offline-accounts', 'bulwark-offline-scope', 'bulwark-offline-health', 'settings-storage', 'theme-storage', 'locale-storage', 'template-storage', 'files-view-mode', ]) localStorage.setItem(key, 'x'); diff --git a/lib/offline/__tests__/storage-health.test.ts b/lib/offline/__tests__/storage-health.test.ts index 49400a0f1..064542837 100644 --- a/lib/offline/__tests__/storage-health.test.ts +++ b/lib/offline/__tests__/storage-health.test.ts @@ -313,6 +313,16 @@ describe('removing an account', () => { }); 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 }) }); From 333c758efa2b1ecc240be9ec9ca13546f1889132 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:02:52 +0100 Subject: [PATCH 22/35] test: fail when the desktop repo's policy schema differs from the webmail's --- lib/desktop/__tests__/policy-templates.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/desktop/__tests__/policy-templates.test.ts b/lib/desktop/__tests__/policy-templates.test.ts index ee4c5b8ee..972ea9a3d 100644 --- a/lib/desktop/__tests__/policy-templates.test.ts +++ b/lib/desktop/__tests__/policy-templates.test.ts @@ -44,6 +44,16 @@ describe('the committed templates', () => { }); }); +describe('the desktop repo', () => { + // Only in a checkout with repos/desktop next to the code. CI's desktop repo has no webmail beside it. + const copy = path.join(process.cwd(), 'repos', 'desktop', 'src', 'shared', 'policy-schema.ts'); + + it.runIf(existsSync(copy))('holds the same schema: run node scripts/generate-desktop-policies.mjs --shell repos/desktop', () => { + const text = (file: string) => readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); + expect(text(copy)).toBe(text(path.join(process.cwd(), 'lib', 'desktop', 'policy-schema.ts'))); + }); +}); + describe('the ADMX', () => { const doc = parseXml(generateAdmx()); const root = doc.documentElement; From fee9fec0859eadf19d0fa2c12b6bd05d70c75da6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:06:19 +0100 Subject: [PATCH 23/35] fix: say that the store was lost also when it opened before the page could show a notice --- components/offline/offline-host.tsx | 2 +- lib/offline/__tests__/storage-health.test.ts | 13 +++++++++++++ lib/offline/offline-mode.ts | 17 +++++++++++++---- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx index 5e4f7cffd..23208b1d8 100644 --- a/components/offline/offline-host.tsx +++ b/components/offline/offline-host.tsx @@ -119,7 +119,7 @@ export function OfflineHost() { return () => { removeGate(); stop(); - setOfflineNotifier(() => {}); + setOfflineNotifier(null); }; }, []); diff --git a/lib/offline/__tests__/storage-health.test.ts b/lib/offline/__tests__/storage-health.test.ts index 064542837..2a4b99dc2 100644 --- a/lib/offline/__tests__/storage-health.test.ts +++ b/lib/offline/__tests__/storage-health.test.ts @@ -185,6 +185,19 @@ describe('a store the browser cleared', () => { 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('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 } }); diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 453b54be8..8966c614b 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -107,11 +107,20 @@ export type OfflineNotice = | { kind: 'forbidden' } | { kind: 'back-online-reload' }; -let notify: (notice: OfflineNotice) => void = () => {}; +/** + * The auth store opens the local store at start, and that can be before the + * host component has mounted: what's said until then waits for it. + */ +const earlyNotices: OfflineNotice[] = []; +const holdNotice = (notice: OfflineNotice) => { + if (earlyNotices.length < 20) earlyNotices.push(notice); +}; +let notify: (notice: OfflineNotice) => void = holdNotice; -/** Where the notices go (the host component turns them into toasts). */ -export function setOfflineNotifier(fn: (notice: OfflineNotice) => void): void { - notify = fn; +/** Where the notices go (the host component turns them into toasts). Null: none is there at the moment. */ +export function setOfflineNotifier(fn: ((notice: OfflineNotice) => void) | null): void { + notify = fn ?? holdNotice; + if (fn) for (const notice of earlyNotices.splice(0)) fn(notice); } export function offlineNotify(notice: OfflineNotice): void { From e215423b5bd794bad4759290b1608f432d75b403 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:24:39 +0100 Subject: [PATCH 24/35] fix: notice a lost store also when the first try to open it had no session yet --- lib/store/__tests__/host.test.ts | 12 ++++++++++++ lib/store/worker/host.ts | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/lib/store/__tests__/host.test.ts b/lib/store/__tests__/host.test.ts index dfe31861d..e0b9ab49b 100644 --- a/lib/store/__tests__/host.test.ts +++ b/lib/store/__tests__/host.test.ts @@ -39,6 +39,18 @@ const params = (file: string, session: unknown = FAKE_SESSION, account = 'a') => account, file, keyHex: KEY, session, scope: DEFAULT_SCOPE, invertedKeywordSort: null, auth: null, }) as Parameters[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(); diff --git a/lib/store/worker/host.ts b/lib/store/worker/host.ts index 1c1d3c035..47f6bb65c 100644 --- a/lib/store/worker/host.ts +++ b/lib/store/worker/host.ts @@ -61,6 +61,12 @@ export class StoreHost { } 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(); From 2edd6b5ea714a57a09bd1b1ef794b75b2e100900 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:06:21 +0100 Subject: [PATCH 25/35] fix: hold forced policies against spoofed plugins, recommended values and stopped servers A forced value could be got around from inside the app. Only a forced value may now decide what's allowed: the keys that do are forced-only, so a recommended one no longer acts as forced. With a plugin list a package can name any id, so uploads are refused and only managed plugins can match. A server taken off AllowedJmapServers stops its accounts without signing out: client, push and sync stop, the switcher drops them, restore and refresh answer 403 with the cookie kept, and listing it again resumes with no sign-in. OAuth and SSO starts check the list too. - `policy.onWipeOfflineData` is gone from the bridge: main runs the wipe - `KeystoreAllowedOrigins` added, and `POLICY_CONSUMERS` names who acts on each key or who still owes it - a Bulwark server's name must be plain after parsing, a wildcard in the auth lists needs a domain of two labels --- app/(main)/[locale]/login/page.tsx | 15 ++- app/api/auth/session/route.ts | 5 + app/api/auth/token/route.ts | 5 + components/layout/account-switcher.tsx | 10 +- components/settings/plugins-settings.tsx | 4 +- desktop-policies/Bulwark.admx | 57 +---------- desktop-policies/README.txt | 5 + desktop-policies/de-DE/Bulwark.adml | 17 ++-- desktop-policies/en-US/Bulwark.adml | 17 ++-- .../org.bulwarkmail.desktop.mobileconfig | 4 + desktop-policies/policies.json | 3 + lib/__tests__/plugin-device-policy.test.ts | 12 ++- lib/__tests__/plugin-sandbox-loader.test.ts | 9 +- lib/desktop/__tests__/policy-client.test.tsx | 35 ++++++- .../__tests__/policy-enforcement.test.ts | 31 ++++++ lib/desktop/__tests__/policy-schema.test.ts | 64 +++++++++++-- .../__tests__/policy-templates.test.ts | 10 +- lib/desktop/policy-schema.ts | 94 +++++++++++++++++-- lib/desktop/policy-templates.ts | 7 +- lib/desktop/policy.ts | 25 +++-- lib/desktop/refused-accounts.ts | 39 ++++++++ lib/desktop/types.ts | 6 -- lib/oauth/custom-upstream.ts | 10 +- lib/offline/offline-mode.ts | 4 + lib/plugin-sandbox/loader.ts | 4 +- stores/auth-store.ts | 71 +++++++++++++- stores/plugin-store.ts | 10 +- 27 files changed, 438 insertions(+), 135 deletions(-) create mode 100644 lib/desktop/refused-accounts.ts diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 61d67df64..8204826d3 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -195,13 +195,16 @@ function LoginPageContent() { // Only the desktop build sets these; a browser never shows them. const [desktopNotice, setDesktopNotice] = useState(null); const [desktopWaiting, setDesktopWaiting] = useState(false); - const [desktopPolicyNotice, setDesktopPolicyNotice] = useState(null); const tSessionLimits = useTranslations("session_limits"); const tOffline = useTranslations("offline"); const tTheme = useTranslations("settings.appearance.theme"); const params = useParams(); const searchParams = useSearchParams(); const isAddAccountMode = searchParams.get("mode") === "add-account"; + // Also when the app sent someone here because their server came off the list. + const [desktopPolicyNotice, setDesktopPolicyNotice] = useState( + searchParams.get("notice") === "server_refused" ? tDesktopPolicy("server_refused") : null, + ); // When the mobile app launches the webmail in a browser tab it tacks on // these params. We grab them once at mount and stash them in a ref so any @@ -630,6 +633,11 @@ function LoginPageContent() { const signedOutOnPurpose = useRef(null); const startServerSideSso = useCallback(async () => { + // The device's OS policy, as for a password: nothing starts towards a refused server. + if (selectedServer?.url && !jmapServerAllowedByDesktopPolicy(selectedServer.url, desktopPolicy)) { + setDesktopPolicyNotice(tDesktopPolicy("server_refused")); + return; + } setOauthLoading(true); try { const prefix = getPathPrefix(params.locale as string); @@ -916,6 +924,11 @@ function LoginPageContent() { * whichever server is selected so that "Sign in with Google" is one tap. */ const handleOAuthLogin = async (target?: PublicJmapServerEntry) => { + const oauthTarget = (target ?? selectedServer)?.url || (allowCustomJmapEndpoint ? jmapEndpoint : configuredServerUrl); + if (oauthTarget && !jmapServerAllowedByDesktopPolicy(oauthTarget, desktopPolicy)) { + setDesktopPolicyNotice(tDesktopPolicy("server_refused")); + return; + } if (LITE_OAUTH_AVAILABLE && !target) { await startLiteOAuth(probeTarget); return; diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 93a7c7217..0a07003f1 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -319,6 +319,11 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); } + // The device's OS policy refuses this server for now: nothing restores, and the + // cookie stays, so the account comes back with no sign-in if the policy does. + const refusedByDevice = devicePolicyServerRefusal(credentials.serverUrl); + if (refusedByDevice) return refusedByDevice; + const age = await checkSessionAge(cookieStore, slot, credentials); if ('maxHours' in age) return sessionTooOld(age.maxHours); diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index ba2d3d9ee..ff07f6b84 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -42,6 +42,7 @@ import { } from '@/lib/auth/session-age'; import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { devicePolicyServerRefusal } from '@/lib/desktop/server-policy'; import { clearCustomUpstream, readCustomUpstream, @@ -277,6 +278,10 @@ export async function PUT(request: NextRequest) { // waits it out. A 500 here would count toward signing the user out. let tokenResponse: Response; const upstream = IS_DESKTOP_BUILD ? await readCustomUpstream(cookieStore, slot) : null; + // The device's OS policy refuses this server for now. The cookies stay, so the + // account comes back with no sign-in if the policy does. + const refusedByDevice = upstream ? devicePolicyServerRefusal(upstream.serverUrl) : null; + if (refusedByDevice) return refusedByDevice; try { const tokenEndpoint = await getTokenEndpoint(serverId, upstream ? { upstream } : { fallbackClientId: DEFAULT_CLIENT_ID }); diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 8ae54ba71..505f868e6 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -6,6 +6,8 @@ import { Check, Plus, LogOut, Mails, Star, ChevronDown, AlertCircle, GripVertica import { useTranslations } from "next-intl"; import type { Mailbox } from "@/lib/jmap/types"; import { useAccountStore, type AccountEntry } from "@/stores/account-store"; +import { useDesktopPolicy } from "@/lib/desktop/policy"; +import { accountRefusedByDesktopPolicy } from "@/lib/desktop/refused-accounts"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; @@ -95,7 +97,13 @@ export function AccountSwitcher({ const tCommon = useTranslations("common"); const ownMailboxes = useEmailStore((s) => s.mailboxes); - const accounts = useAccountStore((s) => s.accounts); + const storedAccounts = useAccountStore((s) => s.accounts); + const devicePolicy = useDesktopPolicy(); + // One on a server the device's OS policy refuses is stopped, and isn't on offer here. + const accounts = useMemo( + () => (devicePolicy.AllowedJmapServers.value ? storedAccounts.filter((account) => !accountRefusedByDesktopPolicy(account, devicePolicy)) : storedAccounts), + [storedAccounts, devicePolicy], + ); const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount); const reorderAccounts = useAccountStore((s) => s.reorderAccounts); // Read activeAccountId from authStore so the selector matches the actually-loaded diff --git a/components/settings/plugins-settings.tsx b/components/settings/plugins-settings.tsx index 105f782b4..3128b37fe 100644 --- a/components/settings/plugins-settings.tsx +++ b/components/settings/plugins-settings.tsx @@ -15,7 +15,7 @@ import { PluginPermissionList } from '@/components/plugins/plugin-permission-lis import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types'; import { effectiveSettings } from '@/lib/plugin-settings'; import { PLUGIN_REFUSED_ERROR } from '@/lib/outgoing-hosts'; -import { PLUGIN_POLICY_REFUSED_ERROR, useDesktopPolicy } from '@/lib/desktop/policy'; +import { PLUGIN_POLICY_REFUSED_ERROR, pluginUploadsAllowedByDesktopPolicy, useDesktopPolicy } from '@/lib/desktop/policy'; import { OrgLockedNote } from './org-locked-note'; import { lookup } from '@/lib/plugin-sandbox/plugin-i18n'; import { forgetCredentials, listConnectedAccounts, type ConnectedAccount } from '@/lib/plugin-sandbox/connect-dialog'; @@ -68,7 +68,7 @@ export function PluginsSettings() { return null; } - const uploadsAllowed = isFeatureEnabled('pluginsUploadEnabled') && !demoMode && devicePolicy.PluginsMode.value !== 'off'; + const uploadsAllowed = isFeatureEnabled('pluginsUploadEnabled') && !demoMode && pluginUploadsAllowedByDesktopPolicy(devicePolicy); const requireApproval = isFeatureEnabled('requirePluginApproval'); const handleToggle = async (plugin: InstalledPlugin) => { diff --git a/desktop-policies/Bulwark.admx b/desktop-policies/Bulwark.admx index 391df08c9..d4b1b0817 100644 --- a/desktop-policies/Bulwark.admx +++ b/desktop-policies/Bulwark.admx @@ -42,12 +42,6 @@ - - - - - - @@ -55,25 +49,12 @@ - - - - - - - - - - - - - @@ -117,17 +98,6 @@ - - - - - - allowed - allowlist - off - - - @@ -135,13 +105,6 @@ - - - - - - - @@ -225,13 +188,6 @@ - - - - - - - @@ -239,13 +195,6 @@ - - - - - - - @@ -253,11 +202,11 @@ - - + + - + diff --git a/desktop-policies/README.txt b/desktop-policies/README.txt index 2e53d392a..5da7aad01 100644 --- a/desktop-policies/README.txt +++ b/desktop-policies/README.txt @@ -8,3 +8,8 @@ macOS: org.bulwarkmail.desktop.mobileconfig is a sample. Change it and deploy it Linux: policies.json is a sample for /etc/bulwark/policies.json. It must be owned by root and writable only by root (mode 644). Otherwise the app refuses to start. + +Limits: these settings stop people from changing them inside the app. The app is +not signed yet, so someone who can write to its folder or run their own copy can +patch it. For a hard guarantee, also limit what may run with AppLocker or WDAC, +and install per machine with the MSI so standard users cannot write to the folder. diff --git a/desktop-policies/de-DE/Bulwark.adml b/desktop-policies/de-DE/Bulwark.adml index b9f25c3a9..fbacf62b3 100644 --- a/desktop-policies/de-DE/Bulwark.adml +++ b/desktop-policies/de-DE/Bulwark.adml @@ -8,7 +8,7 @@ Bulwark Bulwark Desktop Bulwark Desktop: Standardwerte, die Benutzer ändern können - Bulwark Desktop 1.0 und höher + Alle Versionen von Bulwark Desktop Verbindung Updates Plugins @@ -20,7 +20,7 @@ Direkte Verbindungen zu Mailservern zulassen Wenn deaktiviert, öffnet die App nur einen Bulwark-Server, und niemand kann sich direkt bei einem Mailserver anmelden. Früher hinzugefügte Konten bleiben auf dem Datenträger, sind aber ausgeblendet. Wenn Sie einen Bulwark-Server erzwingen und diese Richtlinie nicht konfigurieren, sind direkte Verbindungen ebenfalls aus. Aktivieren Sie sie, um beides anzubieten. Zugelassene Mailserver - Die Mailserver, bei denen sich Benutzer anmelden dürfen. Einer pro Zeile: eine Adresse wie https://mail.example.com oder https://*.example.com für alle Subdomains. Ein Server ohne Schema bedeutet https. Unverschlüsseltes http muss mit http:// angegeben werden. Ist diese Liste gesetzt, wird alles andere abgelehnt. + Die Mailserver, bei denen sich Benutzer anmelden dürfen. Einer pro Zeile: eine Adresse wie https://mail.example.com oder https://*.example.com für alle Subdomains. Ein Server ohne Schema bedeutet https. Unverschlüsseltes http muss mit http:// angegeben werden. Ist diese Liste gesetzt, wird alles andere abgelehnt, und Konten auf einem Server, der von der Liste genommen wird, ruhen, bis er wieder darauf steht, und behalten ihre Daten. Ein Bulwark-Server, den der Benutzer wählt, oder der unter „Bulwark-Server“ als Standardwert muss ebenfalls auf der Liste stehen. Ein erzwungener nicht. Bulwark-Server über unverschlüsseltes http zulassen Wenn aktiviert, darf die Adresse unter „Bulwark-Server“ mit http:// beginnen. Passwörter und E-Mails gehen dann unverschlüsselt über das Netzwerk, und Funktionen, die https brauchen, wie Offline-E-Mails und S/MIME, funktionieren nicht. Lassen Sie dies aus, sofern der Server nicht in einem Netzwerk steht, dem Sie vertrauen. Update-Kanal @@ -30,7 +30,7 @@ Automatisch aktualisieren Wenn deaktiviert, sucht die App nicht nach Updates und lädt keine herunter. Verwenden Sie dies, wenn SCCM, Intune oder ähnliche Werkzeuge die Updates liefern. Welche Plugins laufen dürfen - Ob Plugins installiert und ausgeführt werden dürfen. Ein abgelehntes Plugin behält seine Daten und Einstellungen: Es kommt unverändert zurück, wenn die Richtlinie es wieder erlaubt. + Ob Plugins installiert und ausgeführt werden dürfen. Mit der Liste oder ohne Plugins können Benutzer selbst keine Plugins hinzufügen, weil ein Paket jede beliebige ID nennen kann. Mit der Liste laufen nur Plugins, die Ihr Bulwark-Server oder das Verzeichnis der App bereitstellt. Ein abgelehntes Plugin behält seine Daten und Einstellungen: Es kommt unverändert zurück, wenn die Richtlinie es wieder erlaubt. Alle Plugins sind erlaubt Nur Plugins auf der Liste Keine Plugins @@ -54,30 +54,27 @@ Die Server, an die die App Kerberos-Anmeldedaten delegieren darf, durch Kommas getrennt. Wird beim Neustart der App wirksam. Clientzertifikate automatisch wählen Eine JSON-Regel pro Zeile, im Format von Chrome: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. Die App wählt dann für diese Website das passende Zertifikat ohne Rückfrage. + Websites, die den Zertifikatspeicher des Systems verwenden dürfen + Die Bulwark-Server, nach https-Adresse, deren Plugins mit den Zertifikaten im Speicher des Betriebssystems signieren und entschlüsseln dürfen, ohne dass der Benutzer für jede Website gefragt wird. Eine pro Zeile. Auf dem Gerät behaltene E-Mails löschen - Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alle E-Mails zu löschen, die die App auf dem Gerät behält. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen. + Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen. Zugelassene Mailserver - Zugelassene Mailserver Update-Kanal Update-Kanal Welche Plugins laufen dürfen - Welche Plugins laufen dürfen Plugin-IDs - Plugin-IDs Minuten Minuten - - Regeln - Regeln + Adressen diff --git a/desktop-policies/en-US/Bulwark.adml b/desktop-policies/en-US/Bulwark.adml index 1bf30bacf..5c73606f8 100644 --- a/desktop-policies/en-US/Bulwark.adml +++ b/desktop-policies/en-US/Bulwark.adml @@ -8,7 +8,7 @@ Bulwark Bulwark Desktop Bulwark Desktop: defaults users can change - Bulwark Desktop 1.0 and later + All versions of Bulwark Desktop Connection Updates Plugins @@ -20,7 +20,7 @@ Allow direct connections to mail servers When disabled, the app only opens a Bulwark server and nobody can sign in directly to a mail server. Accounts added earlier stay on the disk, but are hidden. If you force a Bulwark server and leave this not configured, direct connections are off too. Enable it to offer both. Allowed mail servers - The mail servers people may sign in to. One per line: an address such as https://mail.example.com, or https://*.example.com for all subdomains. A server without a scheme means https. Plain http must be named with http://. When this list is set, everything else is refused. + The mail servers people may sign in to. One per line: an address such as https://mail.example.com, or https://*.example.com for all subdomains. A server without a scheme means https. Plain http must be named with http://. When this list is set, everything else is refused, and accounts on a server taken off the list stop until it is back, keeping their data. A Bulwark server the user picks, or the one under "Bulwark server" as a default, must be on the list too. A forced one does not need to be. Allow a Bulwark server on plain http When enabled, the address in "Bulwark server" may start with http://. Passwords and mail then travel unencrypted, and the browser features that need https, such as offline mail and S/MIME, do not work. Leave this off unless the server is on a network you trust. Update channel @@ -30,7 +30,7 @@ Update automatically When disabled, the app does not look for updates or download them. Use this when SCCM, Intune or similar tools deliver updates. Which plugins may run - Whether plugins can be installed and run. A plugin that is refused keeps its data and settings: it comes back as it was if the policy allows it again. + Whether plugins can be installed and run. With the list or none, people cannot add a plugin themselves, since a package can name any id. With the list, only plugins your Bulwark server or the app's directory provides can run. A plugin that is refused keeps its data and settings: it comes back as it was if the policy allows it again. All plugins are allowed Only plugins on the list No plugins @@ -54,30 +54,27 @@ The servers the app may delegate Kerberos credentials to, separated by commas. Takes effect when the app restarts. Choose client certificates automatically One JSON rule per line, in the format Chrome uses: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. The app then picks the matching certificate for that site without asking. + Sites that may use the system certificate store + The Bulwark servers, by https address, whose plugins may sign and decrypt with the certificates in the operating system's store, without the user being asked for each site. One per line. Delete mail kept on the device - Set this to a new value, such as today's date, to delete all mail the app keeps on the device. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again. + Set this to a new value, such as today's date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again. Allowed mail servers - Allowed mail servers Update channel Update channel Which plugins may run - Which plugins may run Plugin ids - Plugin ids Minutes Minutes - - Rules - Rules + Addresses diff --git a/desktop-policies/org.bulwarkmail.desktop.mobileconfig b/desktop-policies/org.bulwarkmail.desktop.mobileconfig index fdb1b400f..474b9f20a 100644 --- a/desktop-policies/org.bulwarkmail.desktop.mobileconfig +++ b/desktop-policies/org.bulwarkmail.desktop.mobileconfig @@ -57,6 +57,10 @@ {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}} + KeystoreAllowedOrigins + + https://mail.example.com + Recommended UpdateChannel diff --git a/desktop-policies/policies.json b/desktop-policies/policies.json index 3c9113c58..965e905fb 100644 --- a/desktop-policies/policies.json +++ b/desktop-policies/policies.json @@ -22,6 +22,9 @@ "AutoSelectCertificateForUrls": [ "{\"pattern\":\"https://[*.]example.com\",\"filter\":{\"ISSUER\":{\"CN\":\"Example CA\"}}}" ], + "KeystoreAllowedOrigins": [ + "https://mail.example.com" + ], "Recommended": { "UpdateChannel": "stable" } diff --git a/lib/__tests__/plugin-device-policy.test.ts b/lib/__tests__/plugin-device-policy.test.ts index 815c56a3f..1b73ae377 100644 --- a/lib/__tests__/plugin-device-policy.test.ts +++ b/lib/__tests__/plugin-device-policy.test.ts @@ -52,7 +52,6 @@ const harness = vi.hoisted(() => { state.listeners.push(listener); return () => {}; }, - onWipeOfflineData: () => () => {}, }, }; return state; @@ -174,15 +173,22 @@ describe('the client gates', () => { expect(usePluginStore.getState().plugins.map((p) => p.id)).toEqual(['kept']); }); - it('installing a package is refused by the id in its manifest', async () => { + it('with a list nobody adds a package, even one naming a listed id', async () => { setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['kept'] }); - vi.mocked(extractPlugin).mockResolvedValue({ valid: true, errors: [], warnings: [], manifest: { id: 'refused-one' }, code: 'x' } as never); + vi.mocked(extractPlugin).mockResolvedValue({ valid: true, errors: [], warnings: [], manifest: { id: 'kept' }, code: 'x' } as never); const result = await usePluginStore.getState().installPlugin(new File(['x'], 'p.zip')); expect(result).toMatchObject({ success: false, reason: 'device-policy' }); expect(usePluginStore.getState().plugins).toEqual([]); expect(pluginStorage.saveCode).not.toHaveBeenCalled(); }); + it('a package the user added earlier under a listed id doesn\'t run', async () => { + setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['refused-one'] }); + usePluginStore.setState({ plugins: [plugin({ managed: false, enabled: false, status: 'installed' })] }); + await usePluginStore.getState().enablePlugin('refused-one'); + expect(usePluginStore.getState().plugins[0]).toMatchObject({ enabled: false, status: 'error', error: 'device-policy' }); + }); + it('turning one on is refused, and says why', async () => { setPolicy({ PluginsMode: 'off' }); usePluginStore.setState({ plugins: [plugin({ enabled: false, status: 'installed' })] }); diff --git a/lib/__tests__/plugin-sandbox-loader.test.ts b/lib/__tests__/plugin-sandbox-loader.test.ts index a09c8511d..bb0c8fccd 100644 --- a/lib/__tests__/plugin-sandbox-loader.test.ts +++ b/lib/__tests__/plugin-sandbox-loader.test.ts @@ -106,7 +106,7 @@ describe('loadSandboxedPlugin under the OS policy of the device', () => { it('starts nothing the policy refuses: no frame, no download', async () => { const { EMPTY_POLICY_LAYERS, resolvePolicies } = await import('../desktop/policy-schema'); const snapshot = (forcedMachine: Record) => resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine }); - const bridgeWith = (current: ReturnType) => ({ version: 1, platform: 'win32' as const, policy: { get: () => current, onChange: () => () => {}, onWipeOfflineData: () => () => {} } }); + const bridgeWith = (current: ReturnType) => ({ version: 1, platform: 'win32' as const, policy: { get: () => current, onChange: () => () => {} } }); window.bulwarkDesktop = bridgeWith(snapshot({ PluginsMode: 'allowlist', PluginAllowlist: ['other-plugin'] })); mocks.getCode.mockResolvedValue(CODE); try { @@ -119,6 +119,13 @@ describe('loadSandboxedPlugin under the OS policy of the device', () => { window.bulwarkDesktop = bridgeWith(snapshot({ PluginsMode: 'allowlist', PluginAllowlist: ['managed-plugin'] })); await loadSandboxedPlugin(plugin()); expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'running' }); + unloadSandboxedPlugin('managed-plugin'); + + // The same id on a package of the user's own doesn't pass. + mocks.createBackgroundInstance.mockClear(); + await loadSandboxedPlugin(plugin({ managed: false })); + expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'error', error: 'device-policy' }); + expect(mocks.createBackgroundInstance).not.toHaveBeenCalled(); } finally { window.bulwarkDesktop = undefined; } diff --git a/lib/desktop/__tests__/policy-client.test.tsx b/lib/desktop/__tests__/policy-client.test.tsx index dd35e920d..37670f0ac 100644 --- a/lib/desktop/__tests__/policy-client.test.tsx +++ b/lib/desktop/__tests__/policy-client.test.tsx @@ -2,7 +2,8 @@ import { render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; import type { BulwarkDesktopBridge } from '../types'; import { EMPTY_POLICY_LAYERS, resolvePolicies, type PolicyLayer, type PolicySnapshot } from '../policy-schema'; -import { customServerAllowedByDesktopPolicy, desktopOfflinePolicy, desktopPolicyValue, jmapServerAllowedByDesktopPolicy, pluginAllowedByDesktopPolicy, useDesktopPolicy } from '../policy'; +import { customServerAllowedByDesktopPolicy, desktopOfflinePolicy, desktopPolicyValue, jmapServerAllowedByDesktopPolicy, pluginAllowedByDesktopPolicy, pluginMayRunByDesktopPolicy, pluginUploadsAllowedByDesktopPolicy, useDesktopPolicy } from '../policy'; +import { accountRefusedByDesktopPolicy, planForRefusedAccounts } from '../refused-accounts'; function snapshotOf(forcedMachine: PolicyLayer, recommendedMachine: PolicyLayer = {}): PolicySnapshot { return resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine, recommendedMachine }); @@ -24,7 +25,6 @@ function setBridgePolicy(snapshot: PolicySnapshot) { listeners.push(listener); return () => { listeners = listeners.filter((l) => l !== listener); }; }, - onWipeOfflineData: () => () => {}, }, } as BulwarkDesktopBridge; } @@ -56,6 +56,34 @@ describe('in the app', () => { expect(jmapServerAllowedByDesktopPolicy('https://other.example')).toBe(false); }); + it('with a list, a package of the user\'s own can\'t pass by naming a listed id, and nobody can add one', () => { + setBridgePolicy(snapshotOf({ PluginsMode: 'allowlist', PluginAllowlist: ['good'] })); + expect(pluginMayRunByDesktopPolicy({ id: 'good', managed: true })).toBe(true); + expect(pluginMayRunByDesktopPolicy({ id: 'good' })).toBe(false); + expect(pluginMayRunByDesktopPolicy({ id: 'bad', managed: true })).toBe(false); + expect(pluginUploadsAllowedByDesktopPolicy()).toBe(false); + setBridgePolicy(snapshotOf({ PluginsMode: 'off' })); + expect(pluginUploadsAllowedByDesktopPolicy()).toBe(false); + setBridgePolicy(snapshotOf({})); + expect(pluginMayRunByDesktopPolicy({ id: 'anything' })).toBe(true); + expect(pluginUploadsAllowedByDesktopPolicy()).toBe(true); + }); + + it('plans what happens to accounts on a server taken off the list', () => { + const accounts = [ + { id: 'a', serverUrl: 'https://off.example', isConnected: true }, + { id: 'b', serverUrl: 'https://on.example', isConnected: false }, + { id: 'c', serverUrl: 'https://on.example', isConnected: true }, + ]; + const list = snapshotOf({ AllowedJmapServers: ['https://on.example'] }); + expect(accountRefusedByDesktopPolicy(accounts[0], list)).toBe(true); + expect(accountRefusedByDesktopPolicy(accounts[1], list)).toBe(false); + expect(planForRefusedAccounts(accounts, 'a', list)).toEqual({ refused: ['a'], stop: ['a'], activeRefused: true, next: 'c' }); + expect(planForRefusedAccounts(accounts, 'b', list)).toEqual({ refused: ['a'], stop: ['a'], activeRefused: false, next: null }); + expect(planForRefusedAccounts([accounts[0]], 'a', list)).toEqual({ refused: ['a'], stop: ['a'], activeRefused: true, next: null }); + expect(planForRefusedAccounts(accounts, 'a', snapshotOf({})).refused).toEqual([]); + }); + it('a forced server hides the field for one of your own, unless direct connections are left on', () => { setBridgePolicy(snapshotOf({ InstanceUrl: 'https://mail.example.com' })); expect(customServerAllowedByDesktopPolicy()).toBe(false); @@ -65,6 +93,9 @@ describe('in the app', () => { expect(customServerAllowedByDesktopPolicy()).toBe(false); setBridgePolicy(snapshotOf({}, { InstanceUrl: 'https://mail.example.com' })); expect(customServerAllowedByDesktopPolicy()).toBe(true); + // A recommendation can't open what a forced server closed. + setBridgePolicy(snapshotOf({ InstanceUrl: 'https://mail.example.com' }, { AllowStandalone: 1 })); + expect(customServerAllowedByDesktopPolicy()).toBe(false); }); it('forced offline goes over the user, recommended under', () => { diff --git a/lib/desktop/__tests__/policy-enforcement.test.ts b/lib/desktop/__tests__/policy-enforcement.test.ts index cffd202a8..94f4c80ed 100644 --- a/lib/desktop/__tests__/policy-enforcement.test.ts +++ b/lib/desktop/__tests__/policy-enforcement.test.ts @@ -69,6 +69,23 @@ vi.mock('@/lib/security/url-guard', async (original) => ({ isPublicHttpUrl: async () => true, })); +const jar = vi.hoisted(() => ({ value: '' })); +const deleted = vi.hoisted(() => [] as string[]); +vi.mock('next/headers', () => ({ + cookies: async () => ({ + get: () => (jar.value ? { value: jar.value } : undefined), + set: () => {}, + delete: (name: string) => deleted.push(name), + getAll: () => [], + }), +})); +vi.mock('@/lib/auth/crypto', () => ({ + encryptSession: () => 'x', + decryptSession: () => ({ serverUrl: 'https://other.example.org', username: 'a', password: 'b' }), + decryptPayload: () => null, + encryptPayload: () => 'x', +})); + const POLICY_KEY = Symbol.for('bulwark.desktop.serverPolicy'); function setPolicy(policy: Partial | undefined) { @@ -198,6 +215,20 @@ describe('AllowedJmapServers on the sign-in routes', () => { expect(fetch).not.toHaveBeenCalled(); }); + it('a stored session on a refused server isn\'t restored, and the cookie stays', async () => { + setPolicy({ pluginsMode: 'allowed', allowedJmapServers: ['https://mail.example.com'] }); + jar.value = 'cookie'; + deleted.length = 0; + const { PUT } = await import('@/app/api/auth/session/route'); + const answer = await PUT(new NextRequest('http://localhost/api/auth/session', { + method: 'PUT', + headers: { 'sec-fetch-site': 'same-origin', 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty' }, + })); + expect(answer.status).toBe(403); + expect((await answer.json()).code).toBe('blocked_by_policy'); + expect(deleted).toEqual([]); + }); + it('the browser sign-in\'s lookup refuses it too', async () => { setPolicy({ pluginsMode: 'allowed', allowedJmapServers: ['https://mail.example.com'] }); const { resolveCustomUpstream } = await import('@/lib/oauth/custom-upstream'); diff --git a/lib/desktop/__tests__/policy-schema.test.ts b/lib/desktop/__tests__/policy-schema.test.ts index be7261483..3411ad9ea 100644 --- a/lib/desktop/__tests__/policy-schema.test.ts +++ b/lib/desktop/__tests__/policy-schema.test.ts @@ -1,8 +1,11 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { DEFAULT_POLICY_SNAPSHOT, EMPTY_POLICY_LAYERS, POLICY_KEYS, + POLICY_CONSUMERS, POLICY_MAX_LIST_ITEMS, POLICY_SCHEMA, certificateMatchesRule, @@ -27,7 +30,7 @@ describe('the schema', () => { 'InstanceUrl', 'AllowStandalone', 'AllowedJmapServers', 'AllowInsecureInstance', 'UpdateChannel', 'AutoUpdate', 'PluginsMode', 'PluginAllowlist', 'OfflineEnabled', 'AppLockRequired', 'AppLockTimeoutMinutes', 'TelemetryAllowed', 'CrashReportsAllowed', 'QuickComposeShortcut', 'AuthServerAllowlist', 'AuthNegotiateDelegateAllowlist', - 'AutoSelectCertificateForUrls', 'WipeOfflineData', + 'AutoSelectCertificateForUrls', 'KeystoreAllowedOrigins', 'WipeOfflineData', ]); for (const key of POLICY_KEYS) { const { title, description } = POLICY_SCHEMA[key]; @@ -46,6 +49,7 @@ describe('precedence', () => { recommendedUser: { AppLockTimeoutMinutes: 9, QuickComposeShortcut: 'Ctrl+Alt+M' }, }; const snapshot = resolvePolicies(all); + expect(resolvePolicies(layers({ recommendedMachine: { PluginsMode: 'off' } })).PluginsMode).toMatchObject({ value: 'allowed', source: 'default' }); expect(snapshot.UpdateChannel).toMatchObject({ value: 'beta', source: 'forced-machine', locked: true }); expect(snapshot.AutoUpdate).toMatchObject({ value: false, source: 'forced-user', locked: true }); expect(snapshot.AppLockTimeoutMinutes).toMatchObject({ value: 5, source: 'recommended-machine', locked: false }); @@ -75,6 +79,17 @@ describe('precedence', () => { }); }); +describe('only a forced value counts for what decides what is allowed', () => { + it.each(['AllowStandalone', 'AllowedJmapServers', 'AllowInsecureInstance', 'PluginsMode', 'PluginAllowlist', 'AuthServerAllowlist', 'AuthNegotiateDelegateAllowlist', 'AutoSelectCertificateForUrls', 'KeystoreAllowedOrigins', 'WipeOfflineData'] as const)('%s', (key) => { + expect(POLICY_SCHEMA[key].forcedOnly).toBe(true); + }); + + it('a recommended AllowInsecureInstance can\'t let a forced http address through', () => { + const snapshot = resolvePolicies(layers({ forcedMachine: { InstanceUrl: 'http://bulwark.lan' }, recommendedMachine: { AllowInsecureInstance: 1 } })); + expect(snapshot.InstanceUrl).toMatchObject({ value: null, invalid: 'insecure' }); + }); +}); + describe('hostile values fail closed', () => { const junk: unknown[] = [{}, [], null, 'a'.repeat(100_000), Number.NaN, { toString: 'x' }, ['x'], -1]; @@ -88,7 +103,7 @@ describe('hostile values fail closed', () => { it('forced values that make no sense take the strict side, and a weaker level never speaks instead', () => { const snapshot = resolvePolicies(layers({ forcedMachine: { AllowStandalone: 'maybe', PluginsMode: 'banana', OfflineEnabled: 7, TelemetryAllowed: 'yes', AppLockRequired: 'no', AllowedJmapServers: 'https://a.example', QuickComposeShortcut: 'rm -rf' }, - recommendedMachine: { AllowStandalone: 1, PluginsMode: 'allowed' }, + recommendedMachine: { TelemetryAllowed: 1 }, })); expect(snapshot.AllowStandalone).toMatchObject({ value: false, locked: true, invalid: 'not_a_switch' }); expect(snapshot.PluginsMode).toMatchObject({ value: 'off', locked: true, invalid: 'unknown_value' }); @@ -101,9 +116,9 @@ describe('hostile values fail closed', () => { }); it('a recommendation that makes no sense is simply not one', () => { - const snapshot = resolvePolicies(layers({ recommendedMachine: { PluginsMode: 'banana' }, recommendedUser: { PluginsMode: 'off' } })); - expect(snapshot.PluginsMode).toMatchObject({ value: 'off', source: 'recommended-user', locked: false }); - expect(snapshot.PluginsMode.invalid).toBeUndefined(); + const snapshot = resolvePolicies(layers({ recommendedMachine: { UpdateChannel: 'banana' }, recommendedUser: { UpdateChannel: 'beta' } })); + expect(snapshot.UpdateChannel).toMatchObject({ value: 'beta', source: 'recommended-user', locked: false }); + expect(snapshot.UpdateChannel.invalid).toBeUndefined(); }); it('a list over the cap is refused as a whole, and bad entries drop out one by one', () => { @@ -145,11 +160,19 @@ describe('InstanceUrl', () => { ['', 'empty'], [`https://${'a'.repeat(3000)}.com`, 'too_long'], ['https://exa\nmple.com', 'invalid_url'], + ['https://exa_mple.com', 'invalid_url'], + ['https://exa"mple.com', 'invalid_url'], [42, 'not_text'], ])('%s is refused', (raw, reason) => { expect(parseInstanceUrl(raw, false)).toEqual({ ok: false, reason }); }); + it('takes an international name as its punycode, and IP literals', () => { + expect(parseInstanceUrl('https://müller.de', false)).toEqual({ ok: true, value: 'https://xn--mller-kva.de/' }); + expect(parseInstanceUrl('https://10.0.0.5:8443', false)).toEqual({ ok: true, value: 'https://10.0.0.5:8443/' }); + expect(parseInstanceUrl('https://[::1]/', false)).toEqual({ ok: true, value: 'https://[::1]/' }); + }); + it('allows http only with AllowInsecureInstance, resolved from the policies', () => { expect(resolvePolicies(layers({ forcedMachine: { InstanceUrl: 'http://bulwark.lan' } })).InstanceUrl).toMatchObject({ value: null, invalid: 'insecure' }); expect(resolvePolicies(layers({ forcedMachine: { InstanceUrl: 'http://bulwark.lan', AllowInsecureInstance: 1 } })).InstanceUrl).toMatchObject({ value: 'http://bulwark.lan/' }); @@ -211,7 +234,7 @@ describe('sign-in lists', () => { expect(parsePolicyValue('AuthServerAllowlist', '*.example.com, intranet')).toEqual({ ok: true, value: '*.example.com,intranet' }); }); - it.each(['*', '*.example.com,*', 'a b', '--no-sandbox x', 'a"b', 'a;b', ''])('%s is refused', (raw) => { + it.each(['*', '*.example.com,*', '*.com', '*example.com', 'a.*.example.com', '*.', 'a b', '--no-sandbox x', 'a"b', 'a;b', ''])('%s is refused', (raw) => { expect(parsePolicyValue('AuthServerAllowlist', raw).ok).toBe(false); }); @@ -293,6 +316,35 @@ describe('AutoSelectCertificateForUrls', () => { }); }); +describe('KeystoreAllowedOrigins', () => { + it('takes https origins and drops the rest', () => { + expect(resolvePolicies(layers({ forcedMachine: { KeystoreAllowedOrigins: ['https://mail.example.com/', 'http://mail.example.com', 'https://mail.example.com/path', 'nonsense'] } })).KeystoreAllowedOrigins).toMatchObject({ value: ['https://mail.example.com'], dropped: 3 }); + }); +}); + +describe('every key has someone acting on it', () => { + it('names a consumer or who owes one', () => { + for (const key of POLICY_KEYS) { + const consumer = POLICY_CONSUMERS[key]; + expect(consumer, key).toBeTruthy(); + if ('pendingConsumer' in consumer) expect(consumer.pendingConsumer.length, key).toBeGreaterThan(5); + } + }); + + it('a consumer in this repo really reads the key', () => { + for (const key of POLICY_KEYS) { + const consumer = POLICY_CONSUMERS[key]; + if ('repo' in consumer && consumer.repo === 'webmail') expect(readFileSync(path.join(process.cwd(), consumer.file), 'utf8'), key).toContain(key); + } + }); + + // The coordinator flips this when step 7 is done: then no key may be waiting. + const ALL_WIRED = false; + it.runIf(ALL_WIRED)('nobody is waiting any more', () => { + expect(POLICY_KEYS.filter((key) => 'pendingConsumer' in POLICY_CONSUMERS[key])).toEqual([]); + }); +}); + describe('defaults', () => { it('nothing set means nothing locked and nothing restricted', () => { for (const key of POLICY_KEYS) expect(DEFAULT_POLICY_SNAPSHOT[key]).toMatchObject({ source: 'default', locked: false }); diff --git a/lib/desktop/__tests__/policy-templates.test.ts b/lib/desktop/__tests__/policy-templates.test.ts index 972ea9a3d..3e4eb91e5 100644 --- a/lib/desktop/__tests__/policy-templates.test.ts +++ b/lib/desktop/__tests__/policy-templates.test.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { POLICY_KEYS, POLICY_RECOMMENDED_SUBKEY, POLICY_REGISTRY_ROOT, POLICY_SCHEMA, parsePolicyValue, resolvePolicies, type PolicyKey } from '../policy-schema'; +import { POLICY_KEYS, POLICY_REGISTRY_ROOT, POLICY_SCHEMA, parsePolicyValue, resolvePolicies, type PolicyKey } from '../policy-schema'; import { generateAdml, generateAdmx, generateMobileconfig, generatePoliciesJson, generatePolicyFiles } from '../policy-templates'; const NS = 'http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions'; @@ -108,7 +108,7 @@ describe('the ADMX', () => { it('puts list policies under their own subkey with numbered values', () => { const lists = POLICY_KEYS.filter((key) => POLICY_SCHEMA[key].type === 'list'); - expect(lists).toEqual(['AllowedJmapServers', 'PluginAllowlist', 'AutoSelectCertificateForUrls']); + expect(lists).toEqual(['AllowedJmapServers', 'PluginAllowlist', 'AutoSelectCertificateForUrls', 'KeystoreAllowedOrigins']); for (const key of lists) { const forced = all(doc, 'policy').find((p) => p.getAttribute('name') === key)!; expect(forced.getAttribute('key')).toBe(`${POLICY_REGISTRY_ROOT}\\${key}`); @@ -116,8 +116,8 @@ describe('the ADMX', () => { const list = all(forced, 'list')[0]; // Empty prefix is what makes Windows name the values 1, 2, 3. expect(list.getAttribute('valuePrefix')).toBe(''); - const recommended = all(doc, 'policy').find((p) => p.getAttribute('name') === `${key}_Recommended`)!; - expect(recommended.getAttribute('key')).toBe(`${POLICY_REGISTRY_ROOT}\\${POLICY_RECOMMENDED_SUBKEY}\\${key}`); + // Lists decide what's allowed, so they can only be forced. + expect(all(doc, 'policy').some((p) => p.getAttribute('name') === `${key}_Recommended`)).toBe(false); } }); @@ -181,6 +181,8 @@ describe('the samples', () => { } expect(snapshot.InstanceUrl.value).toBe('https://mail.example.com/'); expect(snapshot.UpdateChannel.source).toBe('recommended-machine'); + expect(generatePolicyFiles()['README.txt']).toContain('AppLocker'); + expect(generateAdml('en')).not.toContain('1.0 and later'); expect(snapshot.WipeOfflineData.value).toBeNull(); }); diff --git a/lib/desktop/policy-schema.ts b/lib/desktop/policy-schema.ts index 978973d71..3f45d884b 100644 --- a/lib/desktop/policy-schema.ts +++ b/lib/desktop/policy-schema.ts @@ -75,6 +75,7 @@ export interface PolicyValues { AuthServerAllowlist: string | null; AuthNegotiateDelegateAllowlist: string | null; AutoSelectCertificateForUrls: CertificateRule[]; + KeystoreAllowedOrigins: string[]; WipeOfflineData: string | null; } @@ -84,7 +85,11 @@ interface PolicyBase { category: PolicyCategory; title: PolicyText; description: PolicyText; - /** Only a forced value counts: there's no such thing as a recommended wipe. */ + /** + * Only a forced value counts. Anything that decides what's allowed: it has no user + * choice to sit under, so a "recommended" one would act as forced without being + * shown as such, and only a forced value may loosen a forced one. + */ forcedOnly?: boolean; } @@ -123,7 +128,7 @@ export type PolicyDefinition = PolicyBase & } | { type: 'list'; - item: 'server' | 'plugin' | 'certificate'; + item: 'server' | 'plugin' | 'certificate' | 'origin'; default: readonly never[] | null; label: PolicyText; sample: readonly string[]; @@ -147,6 +152,7 @@ export const POLICY_SCHEMA: Record = { }, AllowStandalone: { category: 'connection', + forcedOnly: true, type: 'boolean', default: true, onInvalid: false, @@ -159,6 +165,7 @@ export const POLICY_SCHEMA: Record = { }, AllowedJmapServers: { category: 'connection', + forcedOnly: true, type: 'list', item: 'server', default: null, @@ -166,12 +173,13 @@ export const POLICY_SCHEMA: Record = { label: { en: 'Allowed mail servers', de: 'Zugelassene Mailserver' }, title: { en: 'Allowed mail servers', de: 'Zugelassene Mailserver' }, description: { - en: 'The mail servers people may sign in to. One per line: an address such as https://mail.example.com, or https://*.example.com for all subdomains. A server without a scheme means https. Plain http must be named with http://. When this list is set, everything else is refused.', - de: 'Die Mailserver, bei denen sich Benutzer anmelden dürfen. Einer pro Zeile: eine Adresse wie https://mail.example.com oder https://*.example.com für alle Subdomains. Ein Server ohne Schema bedeutet https. Unverschlüsseltes http muss mit http:// angegeben werden. Ist diese Liste gesetzt, wird alles andere abgelehnt.', + en: 'The mail servers people may sign in to. One per line: an address such as https://mail.example.com, or https://*.example.com for all subdomains. A server without a scheme means https. Plain http must be named with http://. When this list is set, everything else is refused, and accounts on a server taken off the list stop until it is back, keeping their data. A Bulwark server the user picks, or the one under "Bulwark server" as a default, must be on the list too. A forced one does not need to be.', + de: 'Die Mailserver, bei denen sich Benutzer anmelden dürfen. Einer pro Zeile: eine Adresse wie https://mail.example.com oder https://*.example.com für alle Subdomains. Ein Server ohne Schema bedeutet https. Unverschlüsseltes http muss mit http:// angegeben werden. Ist diese Liste gesetzt, wird alles andere abgelehnt, und Konten auf einem Server, der von der Liste genommen wird, ruhen, bis er wieder darauf steht, und behalten ihre Daten. Ein Bulwark-Server, den der Benutzer wählt, oder der unter „Bulwark-Server“ als Standardwert muss ebenfalls auf der Liste stehen. Ein erzwungener nicht.', }, }, AllowInsecureInstance: { category: 'connection', + forcedOnly: true, type: 'boolean', default: false, onInvalid: false, @@ -212,6 +220,7 @@ export const POLICY_SCHEMA: Record = { }, PluginsMode: { category: 'plugins', + forcedOnly: true, type: 'enum', default: 'allowed', onInvalid: 'off', @@ -223,12 +232,13 @@ export const POLICY_SCHEMA: Record = { ], title: { en: 'Which plugins may run', de: 'Welche Plugins laufen dürfen' }, description: { - en: 'Whether plugins can be installed and run. A plugin that is refused keeps its data and settings: it comes back as it was if the policy allows it again.', - de: 'Ob Plugins installiert und ausgeführt werden dürfen. Ein abgelehntes Plugin behält seine Daten und Einstellungen: Es kommt unverändert zurück, wenn die Richtlinie es wieder erlaubt.', + en: 'Whether plugins can be installed and run. With the list or none, people cannot add a plugin themselves, since a package can name any id. With the list, only plugins your Bulwark server or the app\'s directory provides can run. A plugin that is refused keeps its data and settings: it comes back as it was if the policy allows it again.', + de: 'Ob Plugins installiert und ausgeführt werden dürfen. Mit der Liste oder ohne Plugins können Benutzer selbst keine Plugins hinzufügen, weil ein Paket jede beliebige ID nennen kann. Mit der Liste laufen nur Plugins, die Ihr Bulwark-Server oder das Verzeichnis der App bereitstellt. Ein abgelehntes Plugin behält seine Daten und Einstellungen: Es kommt unverändert zurück, wenn die Richtlinie es wieder erlaubt.', }, }, PluginAllowlist: { category: 'plugins', + forcedOnly: true, type: 'list', item: 'plugin', default: [], @@ -318,6 +328,7 @@ export const POLICY_SCHEMA: Record = { }, AuthServerAllowlist: { category: 'signIn', + forcedOnly: true, type: 'string', format: 'hostList', default: null, @@ -332,6 +343,7 @@ export const POLICY_SCHEMA: Record = { }, AuthNegotiateDelegateAllowlist: { category: 'signIn', + forcedOnly: true, type: 'string', format: 'hostList', default: null, @@ -346,6 +358,7 @@ export const POLICY_SCHEMA: Record = { }, AutoSelectCertificateForUrls: { category: 'signIn', + forcedOnly: true, type: 'list', item: 'certificate', default: [], @@ -357,6 +370,20 @@ export const POLICY_SCHEMA: Record = { de: 'Eine JSON-Regel pro Zeile, im Format von Chrome: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. Die App wählt dann für diese Website das passende Zertifikat ohne Rückfrage.', }, }, + KeystoreAllowedOrigins: { + category: 'signIn', + forcedOnly: true, + type: 'list', + item: 'origin', + default: [], + sample: ['https://mail.example.com'], + label: { en: 'Addresses', de: 'Adressen' }, + title: { en: 'Sites that may use the system certificate store', de: 'Websites, die den Zertifikatspeicher des Systems verwenden dürfen' }, + description: { + en: 'The Bulwark servers, by https address, whose plugins may sign and decrypt with the certificates in the operating system\'s store, without the user being asked for each site. One per line.', + de: 'Die Bulwark-Server, nach https-Adresse, deren Plugins mit den Zertifikaten im Speicher des Betriebssystems signieren und entschlüsseln dürfen, ohne dass der Benutzer für jede Website gefragt wird. Eine pro Zeile.', + }, + }, WipeOfflineData: { category: 'security', type: 'string', @@ -368,8 +395,8 @@ export const POLICY_SCHEMA: Record = { label: { en: 'Wipe code', de: 'Löschcode' }, title: { en: 'Delete mail kept on the device', de: 'Auf dem Gerät behaltene E-Mails löschen' }, description: { - en: 'Set this to a new value, such as today\'s date, to delete all mail the app keeps on the device. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again.', - de: 'Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alle E-Mails zu löschen, die die App auf dem Gerät behält. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen.', + en: 'Set this to a new value, such as today\'s date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again.', + de: 'Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen.', }, }, }; @@ -436,6 +463,9 @@ export function parseInstanceUrl(raw: unknown, allowInsecure: boolean): Parsed POLICY_MAX_STRING || CONTROL.test(raw)) return null; + try { + const url = new URL(raw.trim()); + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) return null; + if (url.pathname !== '/' || !/^[a-z0-9.-]+$/.test(url.hostname)) return null; + return url.origin; + } catch { + return null; + } +} + const PLUGIN_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/; export function parsePluginId(raw: unknown): string | null { @@ -526,8 +569,9 @@ function parseHostList(raw: unknown): Parsed { if (!HOST_LIST.test(text)) return fail('invalid_host_list'); const hosts = text.split(',').filter(Boolean); if (hosts.length === 0 || hosts.length > POLICY_MAX_LIST_ITEMS) return fail('invalid_host_list'); - // `*` alone would hand Windows credentials to any site that asks. - if (hosts.some((host) => host === '*' || host === '*.')) return fail('invalid_host_list'); + // A wildcard must sit in front of a real domain of two labels or more: `*` or `*.com` + // would hand Windows credentials to any site that asks. + if (hosts.some((host) => host.includes('*') && !/^\*\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+(?::\d+)?$/.test(host))) return fail('invalid_host_list'); return { ok: true, value: hosts.join(',') }; } @@ -678,6 +722,8 @@ export function parsePolicyValue(key: PolicyKey, raw: unknown, allowInsecure = f return parseList(raw, parsePluginId); case 'certificate': return parseList(raw, parseCertificateRule); + case 'origin': + return parseList(raw, parseHttpsOrigin); } } return fail('unknown_policy'); @@ -756,6 +802,34 @@ export function effectivePolicyValue(entry: { value: T; locked: boolean }, ch export const DEFAULT_POLICY_SNAPSHOT: PolicySnapshot = resolvePolicies(EMPTY_POLICY_LAYERS); +/** + * Who acts on each key, so none can sit idle: the desktop repo's test and the webmail's + * read the named file for the key. `pendingConsumer` names who still has to wire one. + */ +export type PolicyConsumer = { repo: 'shell' | 'webmail'; file: string } | { pendingConsumer: string }; + +export const POLICY_CONSUMERS: Record = { + InstanceUrl: { repo: 'shell', file: 'src/main/policy-start.ts' }, + AllowStandalone: { repo: 'shell', file: 'src/main/policy-start.ts' }, + AllowedJmapServers: { repo: 'shell', file: 'src/main/policy-start.ts' }, + AllowInsecureInstance: { repo: 'shell', file: 'src/main/policy-start.ts' }, + PluginsMode: { repo: 'webmail', file: 'lib/desktop/policy.ts' }, + PluginAllowlist: { repo: 'webmail', file: 'lib/desktop/policy.ts' }, + OfflineEnabled: { repo: 'webmail', file: 'lib/offline/scope.ts' }, + WipeOfflineData: { repo: 'shell', file: 'src/main/features/policies.ts' }, + AuthServerAllowlist: { repo: 'shell', file: 'src/main/features/policies.ts' }, + AuthNegotiateDelegateAllowlist: { repo: 'shell', file: 'src/main/features/policies.ts' }, + AutoUpdate: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' }, + UpdateChannel: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' }, + AppLockRequired: { pendingConsumer: 'step 7, app lock' }, + AppLockTimeoutMinutes: { pendingConsumer: 'step 7, app lock' }, + TelemetryAllowed: { pendingConsumer: 'step 7, telemetry consent' }, + CrashReportsAllowed: { pendingConsumer: 'step 7, telemetry consent' }, + QuickComposeShortcut: { pendingConsumer: 'step 7, quick compose' }, + AutoSelectCertificateForUrls: { pendingConsumer: 'step 7, client certificates' }, + KeystoreAllowedOrigins: { pendingConsumer: 'feat/native-helper: setKeystoreAllowedOrigins in src/main/features/keystore.ts' }, +}; + /** The part the embedded server enforces. Sent to it by message. */ export interface ServerPolicy { pluginsMode: PluginsMode; diff --git a/lib/desktop/policy-templates.ts b/lib/desktop/policy-templates.ts index bd67c4dd4..c9e4b5d09 100644 --- a/lib/desktop/policy-templates.ts +++ b/lib/desktop/policy-templates.ts @@ -141,7 +141,7 @@ const INTRO: PolicyText = { en: 'Settings for the Bulwark desktop app.', de: 'Einstellungen für die Bulwark-Desktop-App.', }; -const SUPPORTED_TEXT: PolicyText = { en: 'Bulwark Desktop 1.0 and later', de: 'Bulwark Desktop 1.0 und höher' }; +const SUPPORTED_TEXT: PolicyText = { en: 'All versions of Bulwark Desktop', de: 'Alle Versionen von Bulwark Desktop' }; const RECOMMENDED_TEXT: PolicyText = { en: 'Bulwark Desktop: defaults users can change', de: 'Bulwark Desktop: Standardwerte, die Benutzer ändern können' }; function presentationFor(key: PolicyKey, id: string, language: Language): string | null { @@ -300,6 +300,11 @@ export function generatePolicyFiles(): Record { `Linux: policies.json is a sample for ${POLICY_LINUX_FILE}. It must be owned by root`, 'and writable only by root (mode 644). Otherwise the app refuses to start.', '', + 'Limits: these settings stop people from changing them inside the app. The app is', + 'not signed yet, so someone who can write to its folder or run their own copy can', + 'patch it. For a hard guarantee, also limit what may run with AppLocker or WDAC,', + 'and install per machine with the MSI so standard users cannot write to the folder.', + '', ].join('\n'), }; } diff --git a/lib/desktop/policy.ts b/lib/desktop/policy.ts index 3c3fad5a3..5e9ec2f2f 100644 --- a/lib/desktop/policy.ts +++ b/lib/desktop/policy.ts @@ -48,14 +48,6 @@ export function onDesktopPolicyChange(listener: (snapshot: PolicySnapshot) => vo return getDesktopBridge()?.policy?.onChange?.(listener) ?? (() => {}); } -/** - * Asks to hear about a forced WipeOfflineData value. The store's wipe goes here: - * when the handler's promise resolves, the shell remembers the value for good. - */ -export function onWipeOfflineData(handler: (nonce: string) => Promise): () => void { - return getDesktopBridge()?.policy?.onWipeOfflineData?.(handler) ?? (() => {}); -} - const serverSnapshot = () => DEFAULT_POLICY_SNAPSHOT; /** The policies, live, for a component. */ @@ -76,6 +68,21 @@ export function pluginAllowedByDesktopPolicy(id: string, snapshot: PolicySnapsho return pluginAllowedByPolicy(snapshot.PluginsMode.value, snapshot.PluginAllowlist.value, id); } +/** + * Whether a plugin may run. A package names its own id, so with a list only what an + * admin deployed (`managed`) can match it: anyone could build a package called the + * same as a listed plugin. + */ +export function pluginMayRunByDesktopPolicy(plugin: { id: string; managed?: boolean }, snapshot: PolicySnapshot = getDesktopPolicy()): boolean { + if (snapshot.PluginsMode.value === 'allowed') return true; + return plugin.managed === true && pluginAllowedByDesktopPolicy(plugin.id, snapshot); +} + +/** With a list or none, nobody adds a package of their own. Same reason. */ +export function pluginUploadsAllowedByDesktopPolicy(snapshot: PolicySnapshot = getDesktopPolicy()): boolean { + return snapshot.PluginsMode.value === 'allowed'; +} + export function jmapServerAllowedByDesktopPolicy(serverUrl: string, snapshot: PolicySnapshot = getDesktopPolicy()): boolean { return jmapServerAllowed(snapshot.AllowedJmapServers.value, serverUrl); } @@ -87,7 +94,7 @@ export function jmapServerAllowedByDesktopPolicy(serverUrl: string, snapshot: Po */ export function customServerAllowedByDesktopPolicy(snapshot: PolicySnapshot = getDesktopPolicy()): boolean { const { AllowStandalone, InstanceUrl } = snapshot; - if (AllowStandalone.source !== 'default') return AllowStandalone.value; + if (AllowStandalone.locked) return AllowStandalone.value; return !InstanceUrl.locked; } diff --git a/lib/desktop/refused-accounts.ts b/lib/desktop/refused-accounts.ts new file mode 100644 index 000000000..26789a570 --- /dev/null +++ b/lib/desktop/refused-accounts.ts @@ -0,0 +1,39 @@ +/** + * Which accounts the device's OS policy refuses, and what follows from it. Pure, so + * the auth store only has to carry it out: a server taken off AllowedJmapServers stops + * its accounts without signing them out. + */ + +import { getDesktopPolicy, jmapServerAllowedByDesktopPolicy } from './policy'; +import type { PolicySnapshot } from './policy-schema'; + +interface AccountLike { + id: string; + serverUrl: string; + isConnected?: boolean; +} + +export function accountRefusedByDesktopPolicy(account: { serverUrl: string }, snapshot: PolicySnapshot = getDesktopPolicy()): boolean { + return !jmapServerAllowedByDesktopPolicy(account.serverUrl, snapshot); +} + +export interface RefusedPlan { + refused: string[]; + /** To stop now: the refused ones, in the order stored. */ + stop: string[]; + activeRefused: boolean; + /** Where the active account moves: a connected one first, as a sign-out does. */ + next: string | null; +} + +export function planForRefusedAccounts(accounts: readonly AccountLike[], activeId: string | null, snapshot: PolicySnapshot): RefusedPlan { + const refused = accounts.filter((account) => accountRefusedByDesktopPolicy(account, snapshot)).map((account) => account.id); + const allowed = accounts.filter((account) => !refused.includes(account.id)); + const activeRefused = !!activeId && refused.includes(activeId); + return { + refused, + stop: refused, + activeRefused, + next: activeRefused ? (allowed.find((account) => account.isConnected) ?? allowed[0])?.id ?? null : null, + }; +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 04d9dac4e..88c16b99a 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -117,12 +117,6 @@ export interface DesktopPolicyBridge { /** What's in force now. Synchronous: a page can read it in its first render. */ get: () => PolicySnapshot; onChange: (listener: (snapshot: PolicySnapshot) => void) => () => void; - /** - * A forced WipeOfflineData value the app hasn't acted on. Delete local mail in the - * handler: when its promise resolves the value counts as done and never comes back, - * also after a restart. If it rejects, the next launch asks again. - */ - onWipeOfflineData: (handler: (nonce: string) => Promise) => () => void; } export interface BulwarkDesktopBridge { diff --git a/lib/oauth/custom-upstream.ts b/lib/oauth/custom-upstream.ts index 24d08396e..b27268959 100644 --- a/lib/oauth/custom-upstream.ts +++ b/lib/oauth/custom-upstream.ts @@ -37,8 +37,12 @@ export function customUpstreamCookieName(slot: number): string { /** Null when the address isn't one the sign-in routes would accept right now. */ export async function resolveCustomUpstream(serverUrl: unknown, clientId: unknown): Promise { - if (typeof serverUrl !== 'string' || !serverUrl || typeof clientId !== 'string' || !clientId) return null; if (mailServerRefusedByDevicePolicy(serverUrl)) return null; + return checkedUpstream(serverUrl, clientId); +} + +async function checkedUpstream(serverUrl: unknown, clientId: unknown): Promise { + if (typeof serverUrl !== 'string' || !serverUrl || typeof clientId !== 'string' || !clientId) return null; await configManager.ensureLoaded(); const servers = parseJmapServers(configManager.get('jmapServers', [])); const trusted = resolveTrustedJmapUrl(serverUrl, configManager.get('jmapServerUrl', ''), servers) !== null; @@ -65,7 +69,9 @@ export async function readCustomUpstream(cookieStore: CookieStore, slot: number) } catch { return null; } - return payload ? resolveCustomUpstream(payload.s, payload.c) : null; + // Without the device's policy: a sign-out still has to revoke its token at a server + // that came off the list. A refresh checks the policy itself. + return payload ? checkedUpstream(payload.s, payload.c) : null; } export function clearCustomUpstream(cookieStore: CookieStore, slot: number): void { diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 8966c614b..a02b826d7 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -39,6 +39,7 @@ import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { usePolicyStore, whenPolicySettled } from '@/stores/policy-store'; import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup'; +import { accountRefusedByDesktopPolicy } from '@/lib/desktop/refused-accounts'; import { generateMessageId } from '@/lib/jmap/client'; import { SendNotStartedError, setOfflineHooks, type OfflineClientRef, type OfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks'; import type { SendEmailResult } from '@/lib/jmap/types'; @@ -678,6 +679,9 @@ export function scheduleSync(account: string, delayMs = 2_000): void { const syncsRunning = new Set(); export async function runSync(account: string): Promise { + // Stopped by the device's OS policy (stores/auth-store.ts): its data waits. + const entry = useAccountStore.getState().accounts.find((candidate) => candidate.id === account); + if (entry && accountRefusedByDesktopPolicy(entry)) return; if (syncsRunning.has(account) || !offlineActive(account)) return; const client = onlineClientFor(account); if (!client || isOfflineNow(client.getServerUrl())) return; diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 000f2c536..bc30cbcff 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -39,7 +39,7 @@ import { hookArgsFor } from './hook-payload'; import { rememberDeclaredHooks } from '../plugin-send-guards'; import type { SlotName } from '../plugin-types'; import { PLUGIN_REFUSED_ERROR, pluginAllowed } from '../outgoing-hosts'; -import { PLUGIN_POLICY_REFUSED_ERROR, pluginAllowedByDesktopPolicy } from '../desktop/policy'; +import { PLUGIN_POLICY_REFUSED_ERROR, pluginMayRunByDesktopPolicy } from '../desktop/policy'; // ─── Hook-bus lookup (one flat map for name → bus) ──────────── @@ -232,7 +232,7 @@ export function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { async function startSandboxedPlugin(plugin: InstalledPlugin): Promise { // The device's OS policy. On an instance this is the only gate there is: the // instance's server knows nothing of this device's policy. - if (!pluginAllowedByDesktopPolicy(plugin.id)) { + if (!pluginMayRunByDesktopPolicy(plugin)) { storeAccessor?.setPluginStatus(plugin.id, 'error', PLUGIN_POLICY_REFUSED_ERROR); return; } diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 9fc0c7ca3..6307b1e0d 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -21,6 +21,8 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in'; import { isEmbedded, notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager'; +import { getDesktopPolicy, onDesktopPolicyChange } from '@/lib/desktop/policy'; +import { accountRefusedByDesktopPolicy, planForRefusedAccounts } from '@/lib/desktop/refused-accounts'; import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope'; import { broadcastSignOut, onSignedOutElsewhere, passSignOutGates, purgeSignedOutData, runBeforeSignOut, type SignOutCleanupOptions } from '@/lib/sign-out-cleanup'; import { forgetPrincipals } from '@/lib/delegation/known-principals'; @@ -2470,7 +2472,7 @@ export const useAuthStore = create()( // Prefer an account that is signed in here: one restored from the // account list backup (or otherwise not connected) has no client, and // picking it signs out every account left. - const remainingAccounts = useAccountStore.getState().accounts; + const remainingAccounts = useAccountStore.getState().accounts.filter((account) => !accountRefusedByDesktopPolicy(account)); const nextAccount = wasDemoMode ? undefined : remainingAccounts.find((a) => clients.has(a.id)) ?? remainingAccounts[0]; @@ -2652,7 +2654,7 @@ export const useAuthStore = create()( const accountStore = useAccountStore.getState(); const targetAccount = accountStore.getAccountById(accountId); - if (!targetAccount) return; + if (!targetAccount || accountRefusedByDesktopPolicy(targetAccount)) return; // Restored from the account list backup and never signed in here: // there is no session to resume, so ask for the password instead. if (targetAccount.awaitingSignIn && !clients.has(accountId)) { @@ -2876,7 +2878,15 @@ export const useAuthStore = create()( checkAuth: async () => { const accountStore = useAccountStore.getState(); - let accounts = accountStore.accounts; + // An account on a server the device's OS policy refuses stays stored, with its + // cookies and local data, and stays out of everything until it's allowed again. + const everyAccount = accountStore.accounts; + let accounts = everyAccount.filter((account) => !accountRefusedByDesktopPolicy(account)); + if (everyAccount.length > 0 && accounts.length === 0) { + set({ isLoading: false, isAuthenticated: false, client: null, activeAccountId: null }); + if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) leaveForRefusedServer(); + return; + } // If every account is a demo account, re-initialize demo mode // instead of trying to restore a server session (which doesn't exist). @@ -2940,8 +2950,9 @@ export const useAuthStore = create()( // Determine which account to activate first const defaultAccount = accountStore.getDefaultAccount(); - const activeId = get().activeAccountId; - const targetId = activeId || defaultAccount?.id || accounts[0].id; + const usable = (id: string | null | undefined) => (id && accounts.some((account) => account.id === id) ? id : null); + const activeId = usable(get().activeAccountId); + const targetId = activeId || usable(defaultAccount?.id) || accounts[0].id; // Restore one account: decrypt the stored credential/token, connect, // and sync the passthrough auth context. The context write never @@ -3665,6 +3676,56 @@ useAuthStore.subscribe((state, prev) => { }); }); +/** Off to the login page, which says why. The account stays, for when the policy allows it again. */ +function leaveForRefusedServer(): void { + if (isPopoutWindow()) { + closePopoutWindow(); + return; + } + replaceWindowLocation(`${getLocaleLoginPath()}?notice=server_refused`); +} + +const stoppedForPolicy = new Set(); + +/** + * The device's OS policy took a server off its list, or put one back. A refused account + * stops: no client, no push, no renewals, and with no client its store has nothing to + * sync through. It isn't signed out: cookies, outbox and mail stay. The active one moves + * to the next allowed account, or to the login page. + */ +export function applyDesktopServerPolicy(): void { + const auth = useAuthStore.getState(); + const accounts = useAccountStore.getState().accounts; + const plan = planForRefusedAccounts(accounts, auth.activeAccountId, getDesktopPolicy()); + for (const id of plan.stop) { + stoppedForPolicy.add(id); + clearRefreshTimer(id); + const client = clients.get(id); + if (client) { + forgetCalendarSubscriptions(client); + try { client.disconnect(); } catch { /* noop */ } + clients.delete(id); + } + useAccountStore.getState().updateAccount(id, { isConnected: false }); + } + const returned = [...stoppedForPolicy].filter((id) => !plan.refused.includes(id)); + for (const id of returned) stoppedForPolicy.delete(id); + + if (plan.activeRefused) { + if (plan.next) void auth.switchAccount(plan.next); + else { + useAuthStore.setState({ client: null, isAuthenticated: false }); + leaveForRefusedServer(); + } + return; + } + // Allowed again. With nobody signed in the start-up path restores it from its cookies; + // otherwise it shows in the switcher again and connects when switched to. + if (returned.length > 0 && !auth.client) window.location.replace('/'); +} + +if (typeof window !== 'undefined') onDesktopPolicyChange(applyDesktopServerPolicy); + // See canEnterLoginInPlace. let signedInSincePageLoad = useAuthStore.getState().isAuthenticated; useAuthStore.subscribe((state) => { diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index f197e8dc2..913662e62 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -27,7 +27,7 @@ import { fetchServerPluginList } from '@/lib/server-plugin-list'; import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types'; import type { Permission } from '@/lib/plugin-types'; import { IS_LITE } from '@/lib/lite'; -import { PLUGIN_POLICY_REFUSED_ERROR, onDesktopPolicyChange, pluginAllowedByDesktopPolicy } from '@/lib/desktop/policy'; +import { PLUGIN_POLICY_REFUSED_ERROR, onDesktopPolicyChange, pluginAllowedByDesktopPolicy, pluginMayRunByDesktopPolicy, pluginUploadsAllowedByDesktopPolicy } from '@/lib/desktop/policy'; import { onFullSignOut } from '@/lib/sign-out-cleanup'; import { forgetDeclaredHooks, setSendGuardSource } from '@/lib/plugin-send-guards'; @@ -144,6 +144,7 @@ export const usePluginStore = create()( installPlugin: async (file: File) => { const policy = usePolicyStore.getState(); + if (!pluginUploadsAllowedByDesktopPolicy()) return { success: false, reason: 'device-policy' }; if (!policy.isFeatureEnabled('pluginsUploadEnabled')) { return { success: false, reason: 'uploads-disabled' }; } @@ -153,9 +154,6 @@ export const usePluginStore = create()( } const { manifest, code } = result; - if (!pluginAllowedByDesktopPolicy(manifest.id)) { - return { success: false, reason: 'device-policy' }; - } const { plugins } = get(); // Check for duplicate @@ -238,7 +236,7 @@ export const usePluginStore = create()( const plugin = plugins.find(p => p.id === id); if (!plugin) return; - if (!pluginAllowedByDesktopPolicy(id)) { + if (!pluginMayRunByDesktopPolicy(plugin)) { set(state => ({ plugins: state.plugins.map(p => (p.id === id ? { ...p, status: 'error', error: PLUGIN_POLICY_REFUSED_ERROR } : p)) })); return; } @@ -842,7 +840,7 @@ async function userPluginMayRun( function applyDesktopPluginPolicy(): void { const { plugins, setPluginStatus } = usePluginStore.getState(); for (const plugin of plugins) { - const allowed = pluginAllowedByDesktopPolicy(plugin.id); + const allowed = pluginMayRunByDesktopPolicy(plugin); if (!allowed && getActivePlugin(plugin.id)) { deactivatePlugin(plugin.id); setPluginStatus(plugin.id, 'error', PLUGIN_POLICY_REFUSED_ERROR); From 14960b11ca8afb04f3ce5e47c41f3af87860dcb9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:28:16 +0100 Subject: [PATCH 26/35] fix: check the OS policy with the current values when SSO starts --- app/(main)/[locale]/login/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 8204826d3..1b81ea206 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -694,7 +694,7 @@ function LoginPageContent() { } catch { setOauthLoading(false); } - }, [params.locale, selectedServer?.id, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState, mobileChallenge]); + }, [params.locale, selectedServer?.id, selectedServer?.url, desktopPolicy, tDesktopPolicy, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState, mobileChallenge]); useEffect(() => { // After signing out, show the login page rather than signing straight From 8d802e4601de8c0ad7d90fddd3d6075976e46217 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:33:53 +0100 Subject: [PATCH 27/35] fix: decide on offline policy only with the whole policy and the user's groups, and ask before losing edits A persisted account id with no subject yet read as decided: Everyone's noOffline wiped mail a group scope allowed, and a wider scope went out at every reload. A public, partial policy counted as the organisation's word, and turned on the desktop default in a forbidding organisation. - an organisation's cap on one file now binds a pin - Clear and the off switch read what would be lost when asked, messages, changes and drafts, and Clear waits for a connection - turning one account off survives the last one going off, and off then on no longer warns of an eviction - a tab that takes over the store sends no scope until it has heard the policy - a dropped account is judged by what applies to anyone - a choice reaches the store once it settles; progress announces phases only - restored Pro tabs ask for their titles --- .../admin/org/sections/offline-section.tsx | 6 +- .../__tests__/account-storage.test.tsx | 94 ++++++++- .../__tests__/offline-settings.test.tsx | 10 +- components/offline/account-storage.tsx | 73 +++++-- components/offline/offline-settings.tsx | 3 +- components/pro/pro-tab-bar.tsx | 2 + .../__tests__/use-keep-file-offline.test.tsx | 63 ++++++ .../use-restored-tab-titles.test.tsx | 46 ++++ hooks/use-keep-file-offline.ts | 25 ++- hooks/use-kept-files.ts | 3 +- hooks/use-restored-tab-titles.ts | 35 ++++ integration/tests/22-ent-offline.spec.ts | 69 +++++- lib/device-policy.ts | 5 + lib/offline/__tests__/client-fallback.test.ts | 2 +- lib/offline/__tests__/drafts-flush.test.ts | 2 +- .../__tests__/effective-scope-wiring.test.ts | 197 +++++++++++++++++- lib/offline/__tests__/handoff-wipes.test.ts | 2 +- lib/offline/__tests__/outbox.test.ts | 2 +- lib/offline/__tests__/queue-sync.test.ts | 2 +- lib/offline/__tests__/review-fixes.test.ts | 2 +- lib/offline/__tests__/scope.test.ts | 28 ++- lib/offline/__tests__/storage-health.test.ts | 63 +++++- lib/offline/__tests__/sync-progress.test.ts | 9 + lib/offline/offline-mode.ts | 86 ++++++-- lib/offline/scope.ts | 1 + lib/offline/state.ts | 15 +- lib/offline/sync-progress.ts | 4 +- lib/store/__tests__/files.test.ts | 26 +++ lib/store/__tests__/page-side.test.ts | 35 ++++ lib/store/area.ts | 5 + lib/store/client/api.ts | 40 ++-- lib/store/client/keys.ts | 6 + lib/store/client/purge.ts | 8 +- lib/store/files/keep.ts | 28 ++- locales/ar/common.json | 12 +- locales/ca/common.json | 12 +- locales/cs/common.json | 12 +- locales/da/common.json | 12 +- locales/de/common.json | 12 +- locales/en/common.json | 14 +- locales/es/common.json | 12 +- locales/et/common.json | 12 +- locales/fa/common.json | 12 +- locales/fr/common.json | 12 +- locales/he/common.json | 12 +- locales/hu/common.json | 12 +- locales/it/common.json | 12 +- locales/ja/common.json | 12 +- locales/ko/common.json | 12 +- locales/lv/common.json | 12 +- locales/mn/common.json | 12 +- locales/nb/common.json | 12 +- locales/nl/common.json | 12 +- locales/pl/common.json | 12 +- locales/pt/common.json | 12 +- locales/ro/common.json | 12 +- locales/ru/common.json | 12 +- locales/sk/common.json | 12 +- locales/th/common.json | 12 +- locales/tr/common.json | 12 +- locales/uk/common.json | 12 +- locales/vi/common.json | 12 +- locales/zh-TW/common.json | 12 +- locales/zh/common.json | 12 +- stores/__tests__/policy-store-partial.test.ts | 29 ++- stores/policy-store.ts | 15 ++ stores/pro-tab-store.ts | 6 +- 67 files changed, 1212 insertions(+), 197 deletions(-) create mode 100644 hooks/__tests__/use-keep-file-offline.test.tsx create mode 100644 hooks/__tests__/use-restored-tab-titles.test.tsx create mode 100644 hooks/use-restored-tab-titles.ts diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx index 5c05dda77..359648627 100644 --- a/components/admin/org/sections/offline-section.tsx +++ b/components/admin/org/sections/offline-section.tsx @@ -25,7 +25,7 @@ const CAP_FIELDS: CapField[] = [ { key: 'offlineMaxAttachmentMb', label: 'Largest attachment or file kept', - description: 'Anything bigger leaves the device and opens from the server when there is a connection. Files someone chose to keep offline count only towards the total below.', + 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, }, @@ -107,8 +107,8 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {

    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 in the browser. That protects against someone copying the browser's - files, not against someone using the unlocked browser. The limits above can only lower what people pick: where several + 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. diff --git a/components/offline/__tests__/account-storage.test.tsx b/components/offline/__tests__/account-storage.test.tsx index 7fcde2e2a..2d214ad4e 100644 --- a/components/offline/__tests__/account-storage.test.tsx +++ b/components/offline/__tests__/account-storage.test.tsx @@ -4,6 +4,8 @@ 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 () => {}), @@ -11,6 +13,8 @@ const offline = vi.hoisted(() => ({ vi.mock('@/lib/offline/offline-mode', async (importOriginal) => ({ ...(await importOriginal()), clearAccount: offline.clearAccount, + disableOffline: offline.disableOffline, + unsentCount: offline.unsentCount, chooseScope: offline.chooseScope, requestPersistence: offline.requestPersistence, dismissAreaConflict: offline.dismissAreaConflict, @@ -21,6 +25,7 @@ vi.mock('@/lib/offline/offline-mode', async (importOriginal) => ({ 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'; @@ -37,7 +42,7 @@ const area = vi.fn(async (_account: string, name: string) => (name === 'files.ke function setDevices(devices: Partial) { usePolicyStore.setState({ - loaded: true, source: 'server', subject: null, + 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); } @@ -140,6 +145,15 @@ describe('what it takes', () => { 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(); + 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({ @@ -163,15 +177,56 @@ describe('what it takes', () => { }); describe('clearing an account', () => { - it('asks first, naming unsent mail', () => { - useOfflineStore.setState({ outbox: [{ id: 1, account: 'acc', createdAt: 1, subject: 'Hi', to: [], status: 'waiting' }] }); + it('asks first, reading then what would be lost: messages and changes', async () => { + offline.unsentCount.mockResolvedValue({ messages: 1, changes: 3 }); render(); 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(); + 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(); + 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(); + 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(); + 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(); + fireEvent.click(screen.getByTestId('offline-switch')); + await waitFor(() => expect(offline.disableOffline).toHaveBeenCalledWith('acc')); + }); }); describe('what the browser may do', () => { @@ -239,6 +294,30 @@ describe('everything that did not go through', () => { }); }); +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(); + 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(); + expect(screen.queryByTestId('offline-desktop-warning')).toBeNull(); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 }; + render(); + expect(screen.getByTestId('offline-desktop-warning').textContent).toBe('warning_desktop'); + }); +}); + describe('files kept offline', () => { it('lists the pinned ones with their state, and can let go of one', async () => { render(); @@ -251,6 +330,15 @@ describe('files kept offline', () => { 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(); + 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' }); diff --git a/components/offline/__tests__/offline-settings.test.tsx b/components/offline/__tests__/offline-settings.test.tsx index 98a6b24f1..3113b413e 100644 --- a/components/offline/__tests__/offline-settings.test.tsx +++ b/components/offline/__tests__/offline-settings.test.tsx @@ -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(); 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(); 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: [] }); }); diff --git a/components/offline/account-storage.tsx b/components/offline/account-storage.tsx index 773ca78e6..a75a09197 100644 --- a/components/offline/account-storage.tsx +++ b/components/offline/account-storage.tsx @@ -1,18 +1,20 @@ 'use client'; -import { useEffect, useState } from 'react'; +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 { isDesktopApp } from '@/lib/desktop/bridge'; import { formatFileSize } from '@/lib/utils'; -import { chooseScope, clearAccount, disableOffline, dismissStoreReset, enableOffline, scopeForAccount } from '@/lib/offline/offline-mode'; +import { chooseScope, clearAccount, disableOffline, dismissStoreReset, enableOffline, scopeForAccount, unsentCount } from '@/lib/offline/offline-mode'; import { attachmentCapOptions, maxAttachmentMb, windowOptions } from '@/lib/offline/scope'; -import { useOfflineStore } from '@/lib/offline/state'; +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; @@ -32,7 +34,9 @@ function ProgressLine({ progress }: { progress: SyncProgress }) { : t('progress_count', { done: format.number(progress.done), total: format.number(progress.total) }); return (

    -

    + {/* Only the phase is announced: the numbers change every moment. */} +

    {phase}

    +

    {phase}: {count}{left !== null && <> · {t('progress_left', { minutes: left })}}

    {progress.total !== null && progress.total > 0 && ( @@ -42,6 +46,22 @@ function ProgressLine({ progress }: { progress: SyncProgress }) { ); } +/** 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'); @@ -54,9 +74,16 @@ export function AccountStorage({ account, primary, heading }: { account: string; const storeReset = useOfflineStore((s) => s.health[account]?.reset); const storageFull = useOfflineStore((s) => !!s.storageFull[account]); const progress = useOfflineStore((s) => s.progress[account]); - const unsent = useOfflineStore((s) => s.outbox.filter((row) => row.account === account).length); + 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); @@ -85,9 +112,27 @@ export function AccountStorage({ account, primary, heading }: { account: string; }; 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 ( -
    +
    {heading &&

    {heading}

    } { if (on) void enableOffline(account); - else if (unsent > 0) setConfirmOff(true); - else void disableOffline(account); + else void turnOff(); }} /> + {isDesktopApp() &&

    {tStorage('warning_desktop')}

    } {storeReset && (

    @@ -114,7 +159,7 @@ export function AccountStorage({ account, primary, heading }: { account: string; {storageFull && (

    {tStorage('storage_full_title')} {tStorage('storage_full_text')}

    - +
    )}
    @@ -127,7 +172,7 @@ export function AccountStorage({ account, primary, heading }: { account: string;

    {syncing && progressWorthShowing(progress) && } {usage && usage.parts.length > 0 && ( -
    +
    {usage.parts.filter((part) => part.items > 0).map((part) => (
    {tStorage(`usage_${part.part}`)}
    @@ -177,8 +222,8 @@ export function AccountStorage({ account, primary, heading }: { account: string; )} - - + + )} @@ -187,7 +232,7 @@ export function AccountStorage({ account, primary, heading }: { account: string; onClose={() => setConfirmOff(false)} onConfirm={() => { setConfirmOff(false); void disableOffline(account); }} title={t('off_confirm_title')} - message={t('off_confirm_message', { count: unsent })} + message={`${tStorage('off_confirm_text', { account: address })} ${lostText}`.trim()} confirmText={t('off_confirm')} variant="destructive" /> @@ -196,7 +241,7 @@ export function AccountStorage({ account, primary, heading }: { account: string; onClose={() => setConfirmClear(false)} onConfirm={() => { setConfirmClear(false); void clearAccount(account); }} title={tStorage('clear_confirm_title')} - message={unsent > 0 ? `${tStorage('clear_confirm_message')} ${tStorage('clear_confirm_unsent', { count: unsent })}` : tStorage('clear_confirm_message')} + message={`${tStorage('clear_confirm_message', { account: address })} ${lostText}`.trim()} confirmText={tStorage('clear_confirm')} variant="destructive" /> diff --git a/components/offline/offline-settings.tsx b/components/offline/offline-settings.tsx index 2dfea8a3a..5271e670f 100644 --- a/components/offline/offline-settings.tsx +++ b/components/offline/offline-settings.tsx @@ -6,6 +6,7 @@ import { AlertTriangle } from '@/components/icons'; import { SettingsSection } from '@/components/settings/settings-section'; import { Button } from '@/components/ui/button'; import { IS_LITE } from '@/lib/lite'; +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'; @@ -61,7 +62,7 @@ export function OfflineSettings() { <>
    {enabledCount > 0 && persist === 'refused' && (
    diff --git a/components/pro/pro-tab-bar.tsx b/components/pro/pro-tab-bar.tsx index 741e3a5a3..755f7b556 100644 --- a/components/pro/pro-tab-bar.tsx +++ b/components/pro/pro-tab-bar.tsx @@ -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(null); // True while an email or folder payload hovers the strip (both drop "anywhere"). diff --git a/hooks/__tests__/use-keep-file-offline.test.tsx b/hooks/__tests__/use-keep-file-offline.test.tsx new file mode 100644 index 000000000..76883e7a0 --- /dev/null +++ b/hooks/__tests__/use-keep-file-offline.test.tsx @@ -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 => ({ files: [] })); + +function setDevices(devices: Partial) { + 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')); + }); +}); diff --git a/hooks/__tests__/use-restored-tab-titles.test.tsx b/hooks/__tests__/use-restored-tab-titles.test.tsx new file mode 100644 index 000000000..bf046b30a --- /dev/null +++ b/hooks/__tests__/use-restored-tab-titles.test.tsx @@ -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); + }); +}); diff --git a/hooks/use-keep-file-offline.ts b/hooks/use-keep-file-offline.ts index aff9cd1cb..31521bec3 100644 --- a/hooks/use-keep-file-offline.ts +++ b/hooks/use-keep-file-offline.ts @@ -1,7 +1,12 @@ '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'; @@ -22,18 +27,32 @@ export function fileRefFor(id: string, ownAccount: string): { jmap: string; 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]); - const canKeepOffline = useCallback((resource: FileRef) => active && !!own && !resource.isDirectory && !resource.isSymlink, [active, 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); - await (keptOffline.has(resource.id) ? unpin(ref) : pin(ref)).catch(() => {}); - }, [own, keptOffline, pin, unpin]); + 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 }; } diff --git a/hooks/use-kept-files.ts b/hooks/use-kept-files.ts index 3a706968f..b897687e1 100644 --- a/hooks/use-kept-files.ts +++ b/hooks/use-kept-files.ts @@ -50,7 +50,8 @@ export function useKeptFiles(account: string | null | undefined) { const unpin = useCallback(async (ref: { jmap: string; id: string }) => { if (!account) return; - await getStore().area(account, 'files.unpin', ref); + // The store being in trouble: the list says what's so. + await getStore().area(account, 'files.unpin', ref).catch(() => {}); await read(); }, [account, read]); diff --git a/hooks/use-restored-tab-titles.ts b/hooks/use-restored-tab-titles.ts new file mode 100644 index 000000000..f26281fc2 --- /dev/null +++ b/hooks/use-restored-tab-titles.ts @@ -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]); +} diff --git a/integration/tests/22-ent-offline.spec.ts b/integration/tests/22-ent-offline.spec.ts index bc24e6fe0..e34302733 100644 --- a/integration/tests/22-ent-offline.spec.ts +++ b/integration/tests/22-ent-offline.spec.ts @@ -967,7 +967,9 @@ async function statusSettled(page: Page): Promise { 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"]'); @@ -998,8 +1000,18 @@ test('a cap from the organisation prunes kept mail at the next policy fetch, and await toMail(page); await blockMailServer(page.context()); await waitUntilOffline(page); - await page.goto('/mail?q=' + encodeURIComponent(`"Old message"`)).catch(() => {}); - await expect(page.locator('[data-testid="email-list-item"]', { hasText: old })).toHaveCount(0); + 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, {}); @@ -1056,8 +1068,20 @@ test('the storage page: usage, choices, clearing an account', async ({ browser } test('the storage page fits at 390 px', async ({ browser }) => { const phone = await userPage(browser, ENT.alice, true); await enableOffline(phone, true); - const overflow = await phone.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); - expect(overflow).toBeLessThanOrEqual(0); + // 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('*')]) { + 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(); }); @@ -1119,10 +1143,40 @@ test('a browser that refuses to keep the data gets a warning and can be asked ag 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 { + 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 { + 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); @@ -1201,13 +1255,16 @@ test('nothing of a kept message is readable on the device', async ({ browser }) await expect(body(page)).toContainText(text, { timeout: 20_000 }); await page.waitForTimeout(1500); - for (const term of [subject, text, `${RUN}quokka`, `${RUN}wombat`]) { + // 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.reload(); + 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([]); diff --git a/lib/device-policy.ts b/lib/device-policy.ts index 3489e28df..22db6991a 100644 --- a/lib/device-policy.ts +++ b/lib/device-policy.ts @@ -84,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(); diff --git a/lib/offline/__tests__/client-fallback.test.ts b/lib/offline/__tests__/client-fallback.test.ts index a4aa5ffa5..fa8106f2b 100644 --- a/lib/offline/__tests__/client-fallback.test.ts +++ b/lib/offline/__tests__/client-fallback.test.ts @@ -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') { diff --git a/lib/offline/__tests__/drafts-flush.test.ts b/lib/offline/__tests__/drafts-flush.test.ts index f9139cf7d..b609b5632 100644 --- a/lib/offline/__tests__/drafts-flush.test.ts +++ b/lib/offline/__tests__/drafts-flush.test.ts @@ -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' }); diff --git a/lib/offline/__tests__/effective-scope-wiring.test.ts b/lib/offline/__tests__/effective-scope-wiring.test.ts index fa421b495..4bf221c12 100644 --- a/lib/offline/__tests__/effective-scope-wiring.test.ts +++ b/lib/offline/__tests__/effective-scope-wiring.test.ts @@ -14,14 +14,17 @@ import { disableOffline, enableOffline, enforcePolicy, + refreshQueueState, + refreshUsage, registerOfflineHooks, scopeForAccount, setOfflineNotifier, startOfflineMode, + unsentCount, wipeEverything, type OfflineNotice, } from '../offline-mode'; -import { OFFLINE_HEALTH_KEY, useOfflineStore, writeEnabled, writeHealth } from '../state'; +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(); @@ -31,7 +34,7 @@ let server: FakeJmap; function setDevices(devices: Partial): void { usePolicyStore.setState({ - loaded: true, source: 'server', subject: null, subjectPending: false, + 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); } @@ -59,6 +62,98 @@ afterEach(async () => { 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 { server.addEmail({ id: 'old', receivedAt: iso(100) }); @@ -112,6 +207,34 @@ describe('the user picking what to keep', () => { 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']); @@ -137,6 +260,63 @@ describe('opened messages', () => { }); }); +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) }); @@ -162,10 +342,16 @@ describe('clearing in another tab', () => { 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(); }); }); @@ -210,13 +396,6 @@ describe('the desktop app', () => { expect(useOfflineStore.getState().choices.acc?.on).toBe(false); }); - it('holds to a cap from the first open', async () => { - asDesktop(); - setDevices({ offlineMaxDays: 14 }); - enforcePolicy(); - await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual(['acc'])); - expect(scopeForAccount('acc').scope.days).toBe(14); - }); }); describe('a browser', () => { diff --git a/lib/offline/__tests__/handoff-wipes.test.ts b/lib/offline/__tests__/handoff-wipes.test.ts index 534a060c8..26699c78d 100644 --- a/lib/offline/__tests__/handoff-wipes.test.ts +++ b/lib/offline/__tests__/handoff-wipes.test.ts @@ -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] }); }); diff --git a/lib/offline/__tests__/outbox.test.ts b/lib/offline/__tests__/outbox.test.ts index f01524e44..1ce7a5c4c 100644 --- a/lib/offline/__tests__/outbox.test.ts +++ b/lib/offline/__tests__/outbox.test.ts @@ -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] }); }); diff --git a/lib/offline/__tests__/queue-sync.test.ts b/lib/offline/__tests__/queue-sync.test.ts index da3099e06..d2ecd5bad 100644 --- a/lib/offline/__tests__/queue-sync.test.ts +++ b/lib/offline/__tests__/queue-sync.test.ts @@ -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] }); }); diff --git a/lib/offline/__tests__/review-fixes.test.ts b/lib/offline/__tests__/review-fixes.test.ts index a5bbc6a0d..c7e4579e5 100644 --- a/lib/offline/__tests__/review-fixes.test.ts +++ b/lib/offline/__tests__/review-fixes.test.ts @@ -56,7 +56,7 @@ function fakeClient(server = 'https://mail.example.org', overrides: Record) { - 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) { diff --git a/lib/offline/__tests__/scope.test.ts b/lib/offline/__tests__/scope.test.ts index 0bc04950c..0073e6b82 100644 --- a/lib/offline/__tests__/scope.test.ts +++ b/lib/offline/__tests__/scope.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +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 { @@ -27,7 +27,7 @@ describe('effectiveScope', () => { 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 }); + 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(); }); @@ -42,8 +42,11 @@ describe('effectiveScope', () => { it('a cap lowers the choice and the default, also "everything", and never raises', () => { const policy = { ...NO_LIMITS, offlineMaxDays: 60, offlineMaxAttachmentMb: 10, offlineMaxTotalMb: 100 }; const capped = effectiveScope('a', { days: null, maxAttachmentBytes: 500 * MB }, policy, DESKTOP); - expect(capped.scope).toEqual({ days: 60, attachments: true, maxAttachmentBytes: 10 * MB, maxTotalBytes: 100 * MB }); + 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); @@ -151,10 +154,27 @@ describe('what the page offers', () => { }); describe('Lite', () => { - it('reads the caps from the same policy.json, and has no store to apply them to', async () => { + 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()), 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(); + } }); }); diff --git a/lib/offline/__tests__/storage-health.test.ts b/lib/offline/__tests__/storage-health.test.ts index 2a4b99dc2..c2013e11e 100644 --- a/lib/offline/__tests__/storage-health.test.ts +++ b/lib/offline/__tests__/storage-health.test.ts @@ -102,7 +102,7 @@ beforeEach(() => { 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: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never); + 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(); @@ -198,12 +198,73 @@ describe('a store the browser cleared', () => { 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((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'); diff --git a/lib/offline/__tests__/sync-progress.test.ts b/lib/offline/__tests__/sync-progress.test.ts index 6bba589c9..419214736 100644 --- a/lib/offline/__tests__/sync-progress.test.ts +++ b/lib/offline/__tests__/sync-progress.test.ts @@ -32,6 +32,15 @@ describe('the time left', () => { 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(); diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 08237e835..6a8c77ac7 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -24,7 +24,7 @@ import { IS_LITE } from '@/lib/lite'; import { desktopJmapBase } from '@/lib/desktop/jmap-route'; -import { devicesForAccount, signedInAccountIds } 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'; @@ -160,9 +160,14 @@ 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), devicesForAccount(account), platformDefault()); + return effectiveScope(account, userChoiceFor(account), policyOfKept(account), platformDefault()); } /** "Keep mail on this device" is on for this account here, and allowed. */ @@ -193,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; @@ -226,6 +234,13 @@ 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; @@ -617,14 +632,23 @@ export async function refreshQueueState(): Promise { const unreadDrafts = shown.localDrafts.filter((row) => failed.includes(row.account)); const accounts = visibleAccounts(); const areas = await Promise.all(accounts.map(readAreaState)); - useOfflineStore.setState({ outbox: [...unread, ...rows], pendingChanges: patches, conflicts, localDrafts: [...unreadDrafts, ...drafts], areaConflicts: areas.flatMap((area) => area.conflicts) }); + 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; + 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 { @@ -680,10 +704,15 @@ export async function refreshUsage(): Promise { const accounts = visibleAccounts(); if (!offlineSupported() || accounts.length === 0) return; try { - const usage = await Promise.all(accounts.map((account) => getStore().area(account, 'store.usage'))); - useOfflineStore.setState({ - usageBytes: usage.reduce((sum, { bytes }) => sum + bytes, 0), - usage: Object.fromEntries(accounts.map((account, index) => [account, usage[index]])), + const settled = await Promise.allSettled(accounts.map((account) => getStore().area(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 */ @@ -889,10 +918,21 @@ export async function clearAccount(account: string): Promise { /** The user changed what an account keeps. Written first, so a reload holds to it. */ export function chooseScope(account: string, patch: UserChoice): void { - writeChoice(account, patch); - pushScope(account); + // 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>(); + /** 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; @@ -968,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(account, `${area}.pending`).catch(() => 0); @@ -1038,7 +1078,8 @@ export async function wipeEverything(): Promise { for (const timer of syncTimers.values()) clearTimeout(timer); syncTimers.clear(); writeEnabled([]); - forgetAllAccountState(); + // Turned off by hand and still signed in: that stays their word. + forgetAllAccountState(signedInAccountIds()); getStore().stop(); await purgeStoreFiles(); try { @@ -1056,8 +1097,9 @@ export async function wipeEverything(): Promise { /** 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 } = usePolicyStore.getState(); - return loaded && (source === 'server' || source === 'cache') && policyDecided(account); + 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. */ @@ -1065,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; @@ -1080,7 +1128,7 @@ 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(); @@ -1189,7 +1237,11 @@ export function startOfflineMode(): () => void { 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(() => {}); + 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; diff --git a/lib/offline/scope.ts b/lib/offline/scope.ts index 270030c32..dcdfd0581 100644 --- a/lib/offline/scope.ts +++ b/lib/offline/scope.ts @@ -104,6 +104,7 @@ export function effectiveScope( 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, diff --git a/lib/offline/state.ts b/lib/offline/state.ts index 18449790b..f85726a57 100644 --- a/lib/offline/state.ts +++ b/lib/offline/state.ts @@ -80,6 +80,8 @@ interface OfflineState { 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; /** 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. */ @@ -139,6 +141,7 @@ export const useOfflineStore = create()(() => ({ pendingChanges: 0, conflicts: [], areaConflicts: [], + changesBy: {}, localDrafts: [], draftToOpen: null, usageBytes: 0, @@ -185,10 +188,14 @@ export function readStoredHealth(): Record { return readRecord(OFFLINE_HEALTH_KEY); } -/** Nothing is kept for anyone any more. */ -export function forgetAllAccountState(): void { - useOfflineStore.setState({ choices: {}, health: {}, usage: {}, storageFull: {}, progress: {}, areaConflicts: [] }); - writeRecord(OFFLINE_SCOPE_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 = {}; + 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, {}); } diff --git a/lib/offline/sync-progress.ts b/lib/offline/sync-progress.ts index 01c1db2e5..945a0375d 100644 --- a/lib/offline/sync-progress.ts +++ b/lib/offline/sync-progress.ts @@ -22,6 +22,8 @@ export interface SyncProgress { 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; @@ -30,7 +32,7 @@ export function nextProgress( 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; + 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, diff --git a/lib/store/__tests__/files.test.ts b/lib/store/__tests__/files.test.ts index 41d158e12..e842a36b6 100644 --- a/lib/store/__tests__/files.test.ts +++ b/lib/store/__tests__/files.test.ts @@ -344,6 +344,32 @@ describe('pinned files', () => { 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); diff --git a/lib/store/__tests__/page-side.test.ts b/lib/store/__tests__/page-side.test.ts index 11e47f920..14651cce7 100644 --- a/lib/store/__tests__/page-side.test.ts +++ b/lib/store/__tests__/page-side.test.ts @@ -447,6 +447,41 @@ describe('the page\'s store', () => { 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((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(); diff --git a/lib/store/area.ts b/lib/store/area.ts index a442ae641..9bdea4dc2 100644 --- a/lib/store/area.ts +++ b/lib/store/area.ts @@ -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 = { diff --git a/lib/store/client/api.ts b/lib/store/client/api.ts index 81c8d2e5b..5c39da0fa 100644 --- a/lib/store/client/api.ts +++ b/lib/store/client/api.ts @@ -7,7 +7,7 @@ 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 { @@ -26,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; /** An account this device kept mail for has an empty store now: its key or its file went. */ onStoreLost?(account: string): void; } @@ -54,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 { @@ -89,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(); - /** What the owner's engine was last told to keep. */ - private readonly sentScope = new Map(); + /** False from taking over the store until this tab has heard the policy again. */ + private scopeCurrent = true; constructor(private readonly hooks: () => StorePageHooks | null) {} @@ -112,6 +113,15 @@ class PageStore implements StoreBackend { }, 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) => { @@ -128,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; @@ -145,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, + scope: this.scopeCurrent ? inputs.scope : undefined, invertedKeywordSort: inputs.invertedKeywordSort, auth: inputs.auth, }); @@ -180,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)) void client.setScope(account, inputs.scope).catch(() => {}); - 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); } @@ -212,7 +217,6 @@ class PageStore implements StoreBackend { } setScope(account: string, scope: StoreScope): Promise { - this.sentScope.set(account, JSON.stringify(scope)); return this.whenOpen(account, (client) => client.setScope(account, scope)); } diff --git a/lib/store/client/keys.ts b/lib/store/client/keys.ts index 9723c6710..059beb916 100644 --- a/lib/store/client/keys.ts +++ b/lib/store/client/keys.ts @@ -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'; diff --git a/lib/store/client/purge.ts b/lib/store/client/purge.ts index 10fb04dfb..5523c58e5 100644 --- a/lib/store/client/purge.ts +++ b/lib/store/client/purge.ts @@ -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 { */ export function purgeStoreFiles(): Promise { 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); diff --git a/lib/store/files/keep.ts b/lib/store/files/keep.ts index 919d7cb97..8bc8ee655 100644 --- a/lib/store/files/keep.ts +++ b/lib/store/files/keep.ts @@ -4,8 +4,9 @@ * * 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 size limit for one file doesn't apply. The - * account's total limit (scope.maxTotalBytes, which a policy can lower) does. + * 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. @@ -121,6 +122,12 @@ function makeRoom(ctx: StoreContext, size: number, incoming: { pinned: boolean; 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, 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); } @@ -177,6 +184,10 @@ export async function fetchKeptFiles(ctx: SyncContext, jmap: string): Promise Math.min(ctx.scope.maxAttachmentBytes, capBytes(ctx))) { setError(db, jmap, blobId, 'tooLarge'); continue; @@ -200,11 +211,11 @@ export async function fetchKeptFiles(ctx: SyncContext, jmap: string): Promise { const now = wantedRow(db, jmap, blobId); if (!now) return; - if (now.pinned ? pinnedBytes(db) + data.length > ctx.scope.maxTotalBytes : data.length > Math.min(ctx.scope.maxAttachmentBytes, capBytes(ctx))) { + 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 ? 'overLimit' : 'tooLarge'); + 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) })) { @@ -239,8 +250,13 @@ export function applyFilesScope(ctx: SyncContext, _change: ScopeChange): void { } const largest = Math.min(scope.maxAttachmentBytes, capBytes(ctx)); for (const blob of keptBlobs(db)) { - if (blob.pinned || Number(blob.size) <= largest) continue; - pushOut(db, blob); + 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; diff --git a/locales/ar/common.json b/locales/ar/common.json index 10fde651f..94f9c24b5 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "خادم البريد لا يستجيب. حاول مرة أخرى بعد قليل." }, "offlineStorage": { - "keep_description": "يحتفظ ببريدك وتقويمك وجهات اتصالك والملفات التي تختارها، مشفّرة في هذا المتصفح. دون اتصال يمكنك قراءة البريد ووضع علامات عليه ونقله، وما تكتبه ينتظر في صندوق الصادر. يؤدي تسجيل الخروج إلى إزالة كل ذلك.", + "keep_description": "يحتفظ ببريدك وتقويمك وجهات اتصالك والملفات التي تختارها، مشفّرة على هذا الجهاز. دون اتصال يمكنك قراءة البريد ووضع علامات عليه ونقله، وما تكتبه ينتظر في صندوق الصادر. يؤدي تسجيل الخروج إلى إزالة كل ذلك.", "window_label": "إلى أي مدة سابقة يُحتفظ بالبريد", "window_description": "يُزال البريد وأحداث التقويم الأقدم من هذا الجهاز. ما زال بإمكانك فتحها عندما يكون لديك اتصال.", "window_days": "{days, plural, zero {# يوم} one {يوم واحد} two {يومان} few {# أيام} many {# يومًا} other {# يوم}}", @@ -5792,7 +5792,7 @@ "clear_description": "يحذف كل ما يحتفظ به هذا الجهاز لهذا الحساب، ثم ينزّله من جديد.", "clear_button": "مسح…", "clear_confirm_title": "هل تريد مسح هذا الحساب على هذا الجهاز؟", - "clear_confirm_message": "سيُحذف كل ما يحتفظ به هذا الجهاز لهذا الحساب ثم يُنزَّل من جديد. يبقى بريدك على الخادم.", + "clear_confirm_message": "سيُحذف كل ما يحتفظ به هذا الجهاز للحساب {account} ثم يُنزَّل من جديد. يبقى بريدك على الخادم.", "clear_confirm_unsent": "{count, plural, zero {لن تُفقد أي رسالة لم تُرسل.} one {ستُفقد رسالة واحدة لم تُرسل.} two {ستُفقد رسالتان لم تُرسلا.} few {ستُفقد # رسائل لم تُرسل.} many {ستُفقد # رسالة لم تُرسل.} other {ستُفقد # رسالة لم تُرسل.}}", "clear_confirm": "مسح", "persist_refused": "قد يحذف هذا المتصفح البريد المحفوظ عندما تقل المساحة. تثبيت التطبيق يمنع ذلك عادةً.", @@ -5816,7 +5816,13 @@ "kept_error_download": "تعذّر التنزيل", "keep_offline": "الاحتفاظ دون اتصال", "unpin": "إيقاف الاحتفاظ دون اتصال", - "unpin_label": "إيقاف الاحتفاظ بـ {name} دون اتصال" + "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": "المسودات على هذا الجهاز", diff --git a/locales/ca/common.json b/locales/ca/common.json index 090d5ed0d..edc005181 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "El servidor de correu no respon. Torneu-ho a provar d'aquí a un moment." }, "offlineStorage": { - "keep_description": "Desa el vostre correu, calendari, contactes i els fitxers que trieu, xifrats en aquest navegador. 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.", + "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}}", @@ -5792,7 +5792,7 @@ "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 d'aquest compte se suprimeix i es torna a baixar. El vostre correu al servidor no es toca.", + "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.", @@ -5816,7 +5816,13 @@ "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ó" + "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", diff --git a/locales/cs/common.json b/locales/cs/common.json index f5bf15a75..89b59af6e 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Poštovní server neodpovídá. Zkuste to za chvíli znovu." }, "offlineStorage": { - "keep_description": "Uchovává vaši poštu, kalendář, kontakty a vybrané soubory zašifrované v tomto prohlížeči. Offline můžete poštu číst, označovat a přesouvat a to, co napíšete, čeká v odesílané poště. Odhlášení vše odstraní.", + "keep_description": "Uchovává vaši poštu, kalendář, kontakty a vybrané soubory zašifrované v tomto zařízení. Offline můžete poštu číst, označovat a přesouvat a to, co napíšete, čeká v odesílané poště. Odhlášení vše odstraní.", "window_label": "Jak dlouho do minulosti uchovávat", "window_description": "Starší pošta a události z kalendáře toto zařízení opustí. Když budete online, můžete je stále otevřít.", "window_days": "{days, plural, one {# den} few {# dny} many {# dne} other {# dní}}", @@ -5792,7 +5792,7 @@ "clear_description": "Smaže vše, co toto zařízení pro tento účet uchovává, a stáhne to znovu.", "clear_button": "Vymazat…", "clear_confirm_title": "Vymazat tento účet na tomto zařízení?", - "clear_confirm_message": "Vše, co toto zařízení pro tento účet uchovává, se smaže a stáhne znovu. Vaše pošta na serveru zůstane.", + "clear_confirm_message": "Vše, co toto zařízení uchovává pro účet {account}, se smaže a stáhne znovu. Vaše pošta na serveru zůstane.", "clear_confirm_unsent": "{count, plural, one {# neodeslaná zpráva se ztratí.} few {# neodeslané zprávy se ztratí.} many {# neodeslané zprávy se ztratí.} other {# neodeslaných zpráv se ztratí.}}", "clear_confirm": "Vymazat", "persist_refused": "Tento prohlížeč může uchovanou poštu smazat, když dojde místo. Instalace aplikace tomu obvykle zabrání.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Nepodařilo se stáhnout", "keep_offline": "Uchovat offline", "unpin": "Přestat uchovávat offline", - "unpin_label": "Přestat uchovávat {name} offline" + "unpin_label": "Přestat uchovávat {name} offline", + "warning_desktop": "Každý, kdo může používat uživatelský účet na tomto počítači, může tuto poštu číst. Použijte šifrování disku.", + "off_confirm_text": "Tím se smaže vše, co toto zařízení uchovává pro účet {account}. Vaše pošta na serveru zůstane.", + "unsent_changes": "{count, plural, one {# změna, která nedorazila na server, se ztratí.} few {# změny, které nedorazily na server, se ztratí.} many {# změny, která nedorazila na server, se ztratí.} other {# změn, které nedorazily na server, se ztratí.}}", + "clear_needs_connection": "Je potřeba připojení, protože se vše stáhne znovu.", + "usage_label": "Co toto zařízení uchovává", + "pin_not_ready": "Tento soubor ještě není v seznamu tohoto zařízení. Zkuste to za chvíli znovu." }, "offlineDrafts": { "title": "Koncepty v tomto zařízení", diff --git a/locales/da/common.json b/locales/da/common.json index c70eff586..14369fc2c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Mailserveren svarer ikke. Prøv igen om lidt." }, "offlineStorage": { - "keep_description": "Beholder din mail, kalender, dine kontakter og de filer, du vælger, krypteret i denne browser. Offline kan du læse, markere og flytte mail, og det, du skriver, venter i udbakken. Når du logger ud, fjernes det hele.", + "keep_description": "Beholder din mail, kalender, dine kontakter og de filer, du vælger, krypteret på denne enhed. Offline kan du læse, markere og flytte mail, og det, du skriver, venter i udbakken. Når du logger ud, fjernes det hele.", "window_label": "Hvor langt tilbage der beholdes", "window_description": "Ældre mail og kalenderbegivenheder forlader denne enhed. Du kan stadig åbne dem, når du har forbindelse.", "window_days": "{days, plural, one {# dag} other {# dage}}", @@ -5792,7 +5792,7 @@ "clear_description": "Sletter alt, denne enhed beholder for denne konto, og henter det derefter igen.", "clear_button": "Ryd…", "clear_confirm_title": "Ryd denne konto på denne enhed?", - "clear_confirm_message": "Alt, denne enhed beholder for denne konto, slettes og hentes igen. Din mail på serveren bliver liggende.", + "clear_confirm_message": "Alt, denne enhed beholder for {account}, slettes og hentes igen. Din mail på serveren bliver liggende.", "clear_confirm_unsent": "{count, plural, one {# usendt besked går tabt.} other {# usendte beskeder går tabt.}}", "clear_confirm": "Ryd", "persist_refused": "Denne browser kan slette den beholdte mail, når pladsen bliver knap. Det hjælper normalt at installere appen.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Kunne ikke hentes", "keep_offline": "Behold offline", "unpin": "Behold ikke længere offline", - "unpin_label": "Behold ikke {name} offline længere" + "unpin_label": "Behold ikke {name} offline længere", + "warning_desktop": "Alle, der kan bruge denne computers brugerkonto, kan læse denne mail. Brug diskkryptering.", + "off_confirm_text": "Det sletter alt, denne enhed beholder for {account}. Din mail på serveren bliver liggende.", + "unsent_changes": "{count, plural, one {# ændring, der ikke er nået frem til serveren, går tabt.} other {# ændringer, der ikke er nået frem til serveren, går tabt.}}", + "clear_needs_connection": "Det kræver en forbindelse, fordi alt hentes igen.", + "usage_label": "Det her beholder denne enhed", + "pin_not_ready": "Denne fil er ikke på denne enheds liste endnu. Prøv igen om lidt." }, "offlineDrafts": { "title": "Kladder på denne enhed", diff --git a/locales/de/common.json b/locales/de/common.json index af3b0b033..04bd88d4f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Der Mailserver antwortet nicht. Versuchen Sie es gleich noch einmal." }, "offlineStorage": { - "keep_description": "Behält Ihre E-Mails, Ihren Kalender, Ihre Kontakte und ausgewählte Dateien verschlüsselt in diesem Browser. Offline können Sie E-Mails lesen, markieren und verschieben, und was Sie schreiben, wartet im Postausgang. Beim Abmelden wird alles entfernt.", + "keep_description": "Behält Ihre E-Mails, Ihren Kalender, Ihre Kontakte und ausgewählte Dateien verschlüsselt auf diesem Gerät. Offline können Sie E-Mails lesen, markieren und verschieben, und was Sie schreiben, wartet im Postausgang. Beim Abmelden wird alles entfernt.", "window_label": "Wie weit zurück behalten", "window_description": "Ältere E-Mails und Kalendertermine werden von diesem Gerät entfernt. Sie können sie weiterhin öffnen, solange Sie online sind.", "window_days": "{days, plural, one {# Tag} other {# Tage}}", @@ -5792,7 +5792,7 @@ "clear_description": "Löscht alles, was dieses Gerät für dieses Konto behält, und lädt es danach neu.", "clear_button": "Leeren…", "clear_confirm_title": "Dieses Konto auf diesem Gerät leeren?", - "clear_confirm_message": "Alles, was dieses Gerät für dieses Konto behält, wird gelöscht und neu geladen. Ihre E-Mails auf dem Server bleiben erhalten.", + "clear_confirm_message": "Alles, was dieses Gerät für {account} behält, wird gelöscht und neu geladen. Ihre E-Mails auf dem Server bleiben erhalten.", "clear_confirm_unsent": "{count, plural, one {# ungesendete Nachricht geht verloren.} other {# ungesendete Nachrichten gehen verloren.}}", "clear_confirm": "Leeren", "persist_refused": "Dieser Browser kann die behaltenen E-Mails löschen, wenn der Platz knapp wird. Die App zu installieren verhindert das meistens.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Konnte nicht geladen werden", "keep_offline": "Offline behalten", "unpin": "Nicht mehr offline behalten", - "unpin_label": "{name} nicht mehr offline behalten" + "unpin_label": "{name} nicht mehr offline behalten", + "warning_desktop": "Jeder, der dieses Benutzerkonto am Computer nutzen kann, kann diese E-Mails lesen. Nutzen Sie eine Festplattenverschlüsselung.", + "off_confirm_text": "Dadurch wird alles gelöscht, was dieses Gerät für {account} behält. Ihre E-Mails auf dem Server bleiben erhalten.", + "unsent_changes": "{count, plural, one {# Änderung, die den Server nicht erreicht hat, geht verloren.} other {# Änderungen, die den Server nicht erreicht haben, gehen verloren.}}", + "clear_needs_connection": "Dafür ist eine Verbindung nötig, weil alles neu geladen wird.", + "usage_label": "Was dieses Gerät behält", + "pin_not_ready": "Diese Datei steht noch nicht auf der Liste dieses Geräts. Versuchen Sie es gleich noch einmal." }, "offlineDrafts": { "title": "Entwürfe auf diesem Gerät", diff --git a/locales/en/common.json b/locales/en/common.json index 6f84023f7..3fbd37931 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "The mail server isn't answering. Try again in a moment." }, "offlineStorage": { - "keep_description": "Keeps your mail, calendar, contacts and chosen files encrypted in this browser. Offline you can read, flag and move mail, and what you write waits in the outbox. Signing out removes all of it.", + "keep_description": "Keeps your mail, calendar, contacts and chosen files encrypted on this device. Offline you can read, flag and move mail, and what you write waits in the outbox. Signing out removes all of it.", "window_label": "How far back to keep", "window_description": "Older mail and calendar events leave this device. You can still open them when you have a connection.", "window_days": "{days, plural, one {# day} other {# days}}", @@ -5792,8 +5792,8 @@ "clear_description": "Deletes everything this device keeps for this account, then downloads it again.", "clear_button": "Clear…", "clear_confirm_title": "Clear this account on this device?", - "clear_confirm_message": "Everything this device keeps for this account is deleted and downloaded again. Your mail on the server stays.", - "clear_confirm_unsent": "{count, plural, one {# unsent message is lost.} other {# unsent messages are lost.}}", + "clear_confirm_message": "Everything this device keeps for {account} is deleted and downloaded again. Your mail on the server stays.", + "clear_confirm_unsent": "{count, plural, one {# message that was not sent is lost.} other {# messages that were not sent are lost.}}", "clear_confirm": "Clear", "persist_refused": "This browser may delete the kept mail when space runs low. Installing the app usually stops that.", "persist_retry": "Ask again", @@ -5816,7 +5816,13 @@ "kept_error_download": "Couldn't be downloaded", "keep_offline": "Keep offline", "unpin": "Stop keeping offline", - "unpin_label": "Stop keeping {name} offline" + "unpin_label": "Stop keeping {name} offline", + "warning_desktop": "Anyone who can use this computer account can read this mail. Use disk encryption.", + "off_confirm_text": "This deletes everything this device keeps for {account}. Your mail on the server stays.", + "unsent_changes": "{count, plural, one {# change that hasn't reached the server is lost.} other {# changes that haven't reached the server are lost.}}", + "clear_needs_connection": "This needs a connection, because everything is downloaded again.", + "usage_label": "What this device keeps", + "pin_not_ready": "This file isn't on this device's list yet. Try again in a moment." }, "offlineDrafts": { "title": "Drafts on this device", diff --git a/locales/es/common.json b/locales/es/common.json index c0c9ed0af..183a2e8f6 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "El servidor de correo no responde. Inténtalo de nuevo en un momento." }, "offlineStorage": { - "keep_description": "Guarda tu correo, tu calendario, tus contactos y los archivos que elijas, cifrados en este navegador. Sin conexión puedes leer, marcar y mover correo, y lo que escribas espera en la bandeja de salida. Al cerrar sesión se borra todo.", + "keep_description": "Guarda tu correo, tu calendario, tus contactos y los archivos que elijas, cifrados en este dispositivo. Sin conexión puedes leer, marcar y mover correo, y lo que escribas espera en la bandeja de salida. Al cerrar sesión se borra todo.", "window_label": "Cuánto tiempo atrás guardar", "window_description": "El correo y los eventos más antiguos salen de este dispositivo. Aún puedes abrirlos cuando tengas conexión.", "window_days": "{days, plural, one {# día} other {# días}}", @@ -5792,7 +5792,7 @@ "clear_description": "Elimina todo lo que este dispositivo guarda de esta cuenta y lo vuelve a descargar.", "clear_button": "Vaciar…", "clear_confirm_title": "¿Vaciar esta cuenta en este dispositivo?", - "clear_confirm_message": "Todo lo que este dispositivo guarda de esta cuenta se elimina y se vuelve a descargar. Tu correo en el servidor se mantiene.", + "clear_confirm_message": "Todo lo que este dispositivo guarda de {account} se elimina y se vuelve a descargar. Tu correo en el servidor se mantiene.", "clear_confirm_unsent": "{count, plural, one {Se perderá # mensaje sin enviar.} other {Se perderán # mensajes sin enviar.}}", "clear_confirm": "Vaciar", "persist_refused": "Este navegador puede borrar el correo guardado cuando falte espacio. Instalar la aplicación suele evitarlo.", @@ -5816,7 +5816,13 @@ "kept_error_download": "No se pudo descargar", "keep_offline": "Guardar sin conexión", "unpin": "Dejar de guardar sin conexión", - "unpin_label": "Dejar de guardar {name} sin conexión" + "unpin_label": "Dejar de guardar {name} sin conexión", + "warning_desktop": "Cualquiera que pueda usar la cuenta de usuario de este ordenador puede leer este correo. Usa el cifrado de disco.", + "off_confirm_text": "Esto elimina todo lo que este dispositivo guarda de {account}. Tu correo en el servidor se mantiene.", + "unsent_changes": "{count, plural, one {Se perderá # cambio que no ha llegado al servidor.} other {Se perderán # cambios que no han llegado al servidor.}}", + "clear_needs_connection": "Hace falta conexión, porque todo se vuelve a descargar.", + "usage_label": "Lo que guarda este dispositivo", + "pin_not_ready": "Este archivo aún no está en la lista de este dispositivo. Inténtalo de nuevo en un momento." }, "offlineDrafts": { "title": "Borradores en este dispositivo", diff --git a/locales/et/common.json b/locales/et/common.json index 9f477d450..9ea087926 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Meiliserver ei vasta. Proovige kohe uuesti." }, "offlineStorage": { - "keep_description": "Hoiab sinu kirju, kalendrit, kontakte ja valitud faile krüptitult selles brauseris. Võrguta saad kirju lugeda, märgistada ja teisaldada ning see, mida kirjutad, ootab väljuvate kirjade kastis. Välja logides eemaldatakse kõik.", + "keep_description": "Hoiab sinu kirju, kalendrit, kontakte ja valitud faile krüptitult selles seadmes. Võrguta saad kirju lugeda, märgistada ja teisaldada ning see, mida kirjutad, ootab väljuvate kirjade kastis. Välja logides eemaldatakse kõik.", "window_label": "Kui kaugele minevikku hoida", "window_description": "Vanemad kirjad ja kalendrisündmused kaovad sellest seadmest. Saad neid endiselt avada, kui oled võrgus.", "window_days": "{days, plural, one {# päev} other {# päeva}}", @@ -5792,7 +5792,7 @@ "clear_description": "Kustutab kõik, mida see seade selle konto jaoks hoiab, ja laadib selle uuesti alla.", "clear_button": "Tühjenda…", "clear_confirm_title": "Kas tühjendada see konto selles seadmes?", - "clear_confirm_message": "Kõik, mida see seade selle konto jaoks hoiab, kustutatakse ja laaditakse uuesti alla. Sinu kirjad serveris jäävad alles.", + "clear_confirm_message": "Kõik, mida see seade kontoga {account} seoses hoiab, kustutatakse ja laaditakse uuesti alla. Sinu kirjad serveris jäävad alles.", "clear_confirm_unsent": "{count, plural, one {# saatmata kiri läheb kaotsi.} other {# saatmata kirja lähevad kaotsi.}}", "clear_confirm": "Tühjenda", "persist_refused": "See brauser võib hoitud kirjad kustutada, kui ruumi napib. Rakenduse paigaldamine hoiab selle tavaliselt ära.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Allalaadimine ebaõnnestus", "keep_offline": "Hoia võrguta", "unpin": "Ära hoia enam võrguta", - "unpin_label": "Ära hoia faili {name} enam võrguta" + "unpin_label": "Ära hoia faili {name} enam võrguta", + "warning_desktop": "Igaüks, kes saab selle arvuti kasutajakontot kasutada, saab neid kirju lugeda. Kasuta kettakrüptimist.", + "off_confirm_text": "See kustutab kõik, mida see seade kontoga {account} seoses hoiab. Sinu kirjad serveris jäävad alles.", + "unsent_changes": "{count, plural, one {# muudatus, mis ei jõudnud serverisse, läheb kaotsi.} other {# muudatust, mis ei jõudnud serverisse, lähevad kaotsi.}}", + "clear_needs_connection": "See vajab ühendust, sest kõik laaditakse uuesti alla.", + "usage_label": "Mida see seade hoiab", + "pin_not_ready": "Seda faili ei ole selle seadme loendis veel. Proovi hetke pärast uuesti." }, "offlineDrafts": { "title": "Mustandid selles seadmes", diff --git a/locales/fa/common.json b/locales/fa/common.json index cd75ac0f5..126945b74 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "سرور ایمیل پاسخ نمی‌دهد. کمی بعد دوباره امتحان کنید." }, "offlineStorage": { - "keep_description": "ایمیل‌ها، تقویم، مخاطبان و فایل‌هایی را که انتخاب می‌کنید به‌صورت رمزگذاری‌شده در این مرورگر نگه می‌دارد. در حالت آفلاین می‌توانید ایمیل‌ها را بخوانید، پرچم بزنید و جابه‌جا کنید، و آنچه می‌نویسید در صندوق خروجی منتظر می‌ماند. با خروج از حساب همه پاک می‌شود.", + "keep_description": "ایمیل‌ها، تقویم، مخاطبان و فایل‌هایی را که انتخاب می‌کنید به‌صورت رمزگذاری‌شده در این دستگاه نگه می‌دارد. در حالت آفلاین می‌توانید ایمیل‌ها را بخوانید، پرچم بزنید و جابه‌جا کنید، و آنچه می‌نویسید در صندوق خروجی منتظر می‌ماند. با خروج از حساب همه پاک می‌شود.", "window_label": "تا چه مدت قبل نگه داشته شود", "window_description": "ایمیل‌ها و رویدادهای تقویم قدیمی‌تر از این دستگاه حذف می‌شوند. وقتی اتصال دارید همچنان می‌توانید آن‌ها را باز کنید.", "window_days": "{days, plural, one {# روز} other {# روز}}", @@ -5792,7 +5792,7 @@ "clear_description": "هر چه این دستگاه برای این حساب نگه می‌دارد حذف می‌کند و دوباره دانلود می‌کند.", "clear_button": "پاک‌کردن…", "clear_confirm_title": "این حساب روی این دستگاه پاک شود؟", - "clear_confirm_message": "هر چه این دستگاه برای این حساب نگه می‌دارد حذف و دوباره دانلود می‌شود. ایمیل‌های شما روی سرور می‌مانند.", + "clear_confirm_message": "هر چه این دستگاه برای {account} نگه می‌دارد حذف و دوباره دانلود می‌شود. ایمیل‌های شما روی سرور می‌مانند.", "clear_confirm_unsent": "{count, plural, one {# پیام ارسال‌نشده از بین می‌رود.} other {# پیام ارسال‌نشده از بین می‌روند.}}", "clear_confirm": "پاک‌کردن", "persist_refused": "وقتی فضا کم شود، این مرورگر ممکن است ایمیل‌های نگه‌داشته‌شده را حذف کند. نصب برنامه معمولاً جلوی این را می‌گیرد.", @@ -5816,7 +5816,13 @@ "kept_error_download": "دانلود نشد", "keep_offline": "نگه داشتن آفلاین", "unpin": "دیگر آفلاین نگه داشته نشود", - "unpin_label": "{name} دیگر آفلاین نگه داشته نشود" + "unpin_label": "{name} دیگر آفلاین نگه داشته نشود", + "warning_desktop": "هر کسی که بتواند از حساب کاربری این رایانه استفاده کند، می‌تواند این ایمیل‌ها را بخواند. از رمزگذاری دیسک استفاده کنید.", + "off_confirm_text": "این کار هر چه این دستگاه برای {account} نگه می‌دارد حذف می‌کند. ایمیل‌های شما روی سرور می‌مانند.", + "unsent_changes": "{count, plural, one {# تغییری که به سرور نرسیده از بین می‌رود.} other {# تغییری که به سرور نرسیده‌اند از بین می‌روند.}}", + "clear_needs_connection": "این کار به اتصال نیاز دارد، چون همه چیز دوباره دانلود می‌شود.", + "usage_label": "آنچه این دستگاه نگه می‌دارد", + "pin_not_ready": "این فایل هنوز در فهرست این دستگاه نیست. کمی بعد دوباره امتحان کنید." }, "offlineDrafts": { "title": "پیش‌نویس‌های این دستگاه", diff --git a/locales/fr/common.json b/locales/fr/common.json index b7e97abce..62c584256 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Le serveur de messagerie ne répond pas. Réessayez dans un instant." }, "offlineStorage": { - "keep_description": "Garde vos e-mails, votre agenda, vos contacts et les fichiers que vous choisissez, chiffrés dans ce navigateur. Hors ligne, vous pouvez lire, signaler et déplacer des e-mails, et ce que vous écrivez attend dans la boîte d'envoi. La déconnexion supprime tout.", + "keep_description": "Garde vos e-mails, votre agenda, vos contacts et les fichiers que vous choisissez, chiffrés sur cet appareil. Hors ligne, vous pouvez lire, signaler et déplacer des e-mails, et ce que vous écrivez attend dans la boîte d'envoi. La déconnexion supprime tout.", "window_label": "Durée de conservation", "window_description": "Les e-mails et événements plus anciens quittent cet appareil. Vous pouvez toujours les ouvrir quand vous êtes connecté.", "window_days": "{days, plural, one {# jour} other {# jours}}", @@ -5792,7 +5792,7 @@ "clear_description": "Supprime tout ce que cet appareil garde pour ce compte, puis le télécharge de nouveau.", "clear_button": "Vider…", "clear_confirm_title": "Vider ce compte sur cet appareil ?", - "clear_confirm_message": "Tout ce que cet appareil garde pour ce compte est supprimé puis téléchargé de nouveau. Vos e-mails sur le serveur restent en place.", + "clear_confirm_message": "Tout ce que cet appareil garde pour {account} est supprimé puis téléchargé de nouveau. Vos e-mails sur le serveur restent en place.", "clear_confirm_unsent": "{count, plural, one {# message non envoyé sera perdu.} other {# messages non envoyés seront perdus.}}", "clear_confirm": "Vider", "persist_refused": "Ce navigateur peut supprimer les e-mails gardés quand la place manque. Installer l'application l'évite en général.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Téléchargement impossible", "keep_offline": "Garder hors ligne", "unpin": "Ne plus garder hors ligne", - "unpin_label": "Ne plus garder {name} hors ligne" + "unpin_label": "Ne plus garder {name} hors ligne", + "warning_desktop": "Toute personne qui peut utiliser le compte utilisateur de cet ordinateur peut lire ces e-mails. Utilisez le chiffrement du disque.", + "off_confirm_text": "Cela supprime tout ce que cet appareil garde pour {account}. Vos e-mails sur le serveur restent en place.", + "unsent_changes": "{count, plural, one {# modification qui n'a pas atteint le serveur sera perdue.} other {# modifications qui n'ont pas atteint le serveur seront perdues.}}", + "clear_needs_connection": "Une connexion est nécessaire, car tout est téléchargé de nouveau.", + "usage_label": "Ce que cet appareil garde", + "pin_not_ready": "Ce fichier n'est pas encore dans la liste de cet appareil. Réessayez dans un instant." }, "offlineDrafts": { "title": "Brouillons sur cet appareil", diff --git a/locales/he/common.json b/locales/he/common.json index d64d300fa..ddc84c647 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5765,7 +5765,7 @@ "server_not_answering": "שרת הדואר לא עונה. אפשר לנסות שוב בעוד רגע." }, "offlineStorage": { - "keep_description": "שומר את הדואר, היומן, אנשי הקשר והקבצים שבחרת, מוצפנים בדפדפן הזה. במצב לא מקוון אפשר לקרוא, לסמן ולהעביר דואר, ומה שכותבים ממתין בתיבת הדואר היוצא. התנתקות מוחקת הכול.", + "keep_description": "שומר את הדואר, היומן, אנשי הקשר והקבצים שבחרת, מוצפנים במכשיר הזה. במצב לא מקוון אפשר לקרוא, לסמן ולהעביר דואר, ומה שכותבים ממתין בתיבת הדואר היוצא. התנתקות מוחקת הכול.", "window_label": "כמה זמן אחורה לשמור", "window_description": "דואר ואירועי יומן ישנים יותר יוסרו מהמכשיר הזה. אפשר עדיין לפתוח אותם כשיש חיבור.", "window_days": "{days, plural, one {יום #} two {יומיים} other {# ימים}}", @@ -5793,7 +5793,7 @@ "clear_description": "מוחק את כל מה שהמכשיר הזה שומר עבור החשבון הזה, ואז מוריד אותו מחדש.", "clear_button": "ניקוי…", "clear_confirm_title": "לנקות את החשבון הזה במכשיר הזה?", - "clear_confirm_message": "כל מה שהמכשיר הזה שומר עבור החשבון הזה יימחק ויורד מחדש. הדואר שלך בשרת נשאר.", + "clear_confirm_message": "כל מה שהמכשיר הזה שומר עבור {account} יימחק ויורד מחדש. הדואר שלך בשרת נשאר.", "clear_confirm_unsent": "{count, plural, one {הודעה אחת שלא נשלחה תאבד.} two {# הודעות שלא נשלחו יאבדו.} other {# הודעות שלא נשלחו יאבדו.}}", "clear_confirm": "ניקוי", "persist_refused": "הדפדפן הזה עלול למחוק את הדואר השמור כשנגמר המקום. התקנת האפליקציה בדרך כלל מונעת זאת.", @@ -5817,7 +5817,13 @@ "kept_error_download": "לא ניתן היה להוריד", "keep_offline": "שמירה במצב לא מקוון", "unpin": "הפסקת שמירה במצב לא מקוון", - "unpin_label": "הפסקת שמירה של {name} במצב לא מקוון" + "unpin_label": "הפסקת שמירה של {name} במצב לא מקוון", + "warning_desktop": "כל מי שיכול להשתמש בחשבון המשתמש במחשב הזה יכול לקרוא את הדואר הזה. השתמש בהצפנת דיסק.", + "off_confirm_text": "פעולה זו מוחקת את כל מה שהמכשיר הזה שומר עבור {account}. הדואר שלך בשרת נשאר.", + "unsent_changes": "{count, plural, one {שינוי אחד שלא הגיע לשרת יאבד.} two {# שינויים שלא הגיעו לשרת יאבדו.} other {# שינויים שלא הגיעו לשרת יאבדו.}}", + "clear_needs_connection": "נדרש חיבור, כי הכול יורד מחדש.", + "usage_label": "מה שהמכשיר הזה שומר", + "pin_not_ready": "הקובץ הזה עוד לא ברשימה של המכשיר הזה. נסה שוב בעוד רגע." }, "offlineDrafts": { "title": "טיוטות במכשיר זה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 5d4a9dd70..f44e5c82c 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "A levelezőszerver nem válaszol. Próbálja újra egy pillanat múlva." }, "offlineStorage": { - "keep_description": "Megtartja a leveleit, a naptárát, a névjegyeit és a kiválasztott fájlokat, titkosítva ebben a böngészőben. Offline olvashat, megjelölhet és áthelyezhet leveleket, a megírt levelek pedig a postázandók között várnak. Kijelentkezéskor minden törlődik.", + "keep_description": "Megtartja a leveleit, a naptárát, a névjegyeit és a kiválasztott fájlokat, titkosítva ezen az eszközön. Offline olvashat, megjelölhet és áthelyezhet leveleket, a megírt levelek pedig a postázandók között várnak. Kijelentkezéskor minden törlődik.", "window_label": "Mennyi időre visszamenőleg maradjon meg", "window_description": "A régebbi levelek és naptáresemények lekerülnek erről az eszközről. Online állapotban továbbra is megnyithatja őket.", "window_days": "{days, plural, one {# nap} other {# nap}}", @@ -5792,7 +5792,7 @@ "clear_description": "Törli mindazt, amit ez az eszköz ehhez a fiókhoz megtart, majd újra letölti.", "clear_button": "Kiürítés…", "clear_confirm_title": "Kiüríti a fiókot ezen az eszközön?", - "clear_confirm_message": "Minden, amit ez az eszköz ehhez a fiókhoz megtart, törlődik és újra letöltődik. A szerveren lévő levelei megmaradnak.", + "clear_confirm_message": "Minden, amit ez az eszköz a(z) {account} fiókhoz megtart, törlődik és újra letöltődik. A szerveren lévő levelei megmaradnak.", "clear_confirm_unsent": "{count, plural, one {# el nem küldött levél elvész.} other {# el nem küldött levél elvész.}}", "clear_confirm": "Kiürítés", "persist_refused": "Ez a böngésző törölheti a megtartott leveleket, ha fogy a hely. Az alkalmazás telepítése ezt általában megakadályozza.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Nem sikerült letölteni", "keep_offline": "Megtartás offline", "unpin": "Megtartás offline megszüntetése", - "unpin_label": "{name} offline megtartásának megszüntetése" + "unpin_label": "{name} offline megtartásának megszüntetése", + "warning_desktop": "Aki használhatja ennek a számítógépnek a felhasználói fiókját, elolvashatja ezeket a leveleket. Használjon lemeztitkosítást.", + "off_confirm_text": "Ez törli mindazt, amit ez az eszköz a(z) {account} fiókhoz megtart. A szerveren lévő levelei megmaradnak.", + "unsent_changes": "{count, plural, one {# módosítás, amely nem jutott el a szerverre, elvész.} other {# módosítás, amely nem jutott el a szerverre, elvész.}}", + "clear_needs_connection": "Ehhez kapcsolat kell, mert minden újra letöltődik.", + "usage_label": "Mit tart meg ez az eszköz", + "pin_not_ready": "Ez a fájl még nincs rajta az eszköz listáján. Próbálja újra egy pillanat múlva." }, "offlineDrafts": { "title": "Piszkozatok ezen az eszközön", diff --git a/locales/it/common.json b/locales/it/common.json index 58ba03188..ead7132ec 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Il server di posta non risponde. Riprova tra un momento." }, "offlineStorage": { - "keep_description": "Conserva la tua posta, il calendario, i contatti e i file che scegli, cifrati in questo browser. Offline puoi leggere, contrassegnare e spostare la posta, e ciò che scrivi aspetta nella posta in uscita. Uscendo dall'account viene rimosso tutto.", + "keep_description": "Conserva la tua posta, il calendario, i contatti e i file che scegli, cifrati su questo dispositivo. Offline puoi leggere, contrassegnare e spostare la posta, e ciò che scrivi aspetta nella posta in uscita. Uscendo dall'account viene rimosso tutto.", "window_label": "Da quanto tempo conservare", "window_description": "La posta e gli eventi più vecchi lasciano questo dispositivo. Puoi comunque aprirli quando sei online.", "window_days": "{days, plural, one {# giorno} other {# giorni}}", @@ -5792,7 +5792,7 @@ "clear_description": "Elimina tutto ciò che questo dispositivo conserva per questo account, poi lo scarica di nuovo.", "clear_button": "Svuota…", "clear_confirm_title": "Svuotare questo account su questo dispositivo?", - "clear_confirm_message": "Tutto ciò che questo dispositivo conserva per questo account viene eliminato e scaricato di nuovo. La tua posta sul server resta dov'è.", + "clear_confirm_message": "Tutto ciò che questo dispositivo conserva per {account} viene eliminato e scaricato di nuovo. La tua posta sul server resta dov'è.", "clear_confirm_unsent": "{count, plural, one {# messaggio non inviato andrà perso.} other {# messaggi non inviati andranno persi.}}", "clear_confirm": "Svuota", "persist_refused": "Questo browser può eliminare la posta conservata quando lo spazio scarseggia. Installare l'app di solito lo evita.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Impossibile scaricare", "keep_offline": "Conserva offline", "unpin": "Non conservare più offline", - "unpin_label": "Non conservare più {name} offline" + "unpin_label": "Non conservare più {name} offline", + "warning_desktop": "Chiunque possa usare l'account utente di questo computer può leggere questa posta. Usa la cifratura del disco.", + "off_confirm_text": "Questo elimina tutto ciò che questo dispositivo conserva per {account}. La tua posta sul server resta dov'è.", + "unsent_changes": "{count, plural, one {# modifica che non ha raggiunto il server andrà persa.} other {# modifiche che non hanno raggiunto il server andranno perse.}}", + "clear_needs_connection": "Serve una connessione, perché viene scaricato di nuovo tutto.", + "usage_label": "Cosa conserva questo dispositivo", + "pin_not_ready": "Questo file non è ancora nell'elenco di questo dispositivo. Riprova tra un momento." }, "offlineDrafts": { "title": "Bozze su questo dispositivo", diff --git a/locales/ja/common.json b/locales/ja/common.json index 3532ea448..082f5243a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "メールサーバーが応答していません。少ししてからもう一度お試しください。" }, "offlineStorage": { - "keep_description": "メール、カレンダー、連絡先、選んだファイルを暗号化してこのブラウザーに保存します。オフラインでもメールを読む、フラグを付ける、移動することができ、書いたメールは送信トレイで待機します。サインアウトするとすべて削除されます。", + "keep_description": "メール、カレンダー、連絡先、選んだファイルを暗号化してこのデバイスに保存します。オフラインでもメールを読む、フラグを付ける、移動することができ、書いたメールは送信トレイで待機します。サインアウトするとすべて削除されます。", "window_label": "保存する期間", "window_description": "これより古いメールとカレンダーの予定はこのデバイスから削除されます。オンラインのときは引き続き開けます。", "window_days": "{days, plural, other {#日}}", @@ -5792,7 +5792,7 @@ "clear_description": "このデバイスに保存されているこのアカウントのデータをすべて削除し、あらためてダウンロードします。", "clear_button": "消去…", "clear_confirm_title": "このデバイスでこのアカウントを消去しますか?", - "clear_confirm_message": "このデバイスに保存されているこのアカウントのデータをすべて削除し、あらためてダウンロードします。サーバー上のメールはそのまま残ります。", + "clear_confirm_message": "このデバイスに保存されている{account}のデータをすべて削除し、あらためてダウンロードします。サーバー上のメールはそのまま残ります。", "clear_confirm_unsent": "{count, plural, other {未送信のメッセージ#件が失われます。}}", "clear_confirm": "消去", "persist_refused": "空き容量が少なくなると、このブラウザーは保存したメールを削除することがあります。アプリをインストールすると、たいていは防げます。", @@ -5816,7 +5816,13 @@ "kept_error_download": "ダウンロードできませんでした", "keep_offline": "オフライン用に保存", "unpin": "オフライン保存をやめる", - "unpin_label": "{name} のオフライン保存をやめる" + "unpin_label": "{name} のオフライン保存をやめる", + "warning_desktop": "このパソコンのユーザーアカウントを使える人は、このメールを読めます。ディスクを暗号化してください。", + "off_confirm_text": "このデバイスに保存されている{account}のデータをすべて削除します。サーバー上のメールはそのまま残ります。", + "unsent_changes": "{count, plural, other {サーバーに届いていない変更#件が失われます。}}", + "clear_needs_connection": "すべてをあらためてダウンロードするため、接続が必要です。", + "usage_label": "このデバイスに保存しているもの", + "pin_not_ready": "このファイルはまだこのデバイスのリストにありません。しばらくしてからもう一度お試しください。" }, "offlineDrafts": { "title": "このデバイスの下書き", diff --git a/locales/ko/common.json b/locales/ko/common.json index 4b48fc595..46fba2617 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "메일 서버가 응답하지 않습니다. 잠시 후 다시 시도하세요." }, "offlineStorage": { - "keep_description": "메일, 캘린더, 연락처, 선택한 파일을 이 브라우저에 암호화해 보관합니다. 오프라인에서도 메일을 읽고, 플래그를 지정하고, 이동할 수 있으며, 작성한 메일은 보낼편지함에서 대기합니다. 로그아웃하면 모두 삭제됩니다.", + "keep_description": "메일, 캘린더, 연락처, 선택한 파일을 이 기기에 암호화해 보관합니다. 오프라인에서도 메일을 읽고, 플래그를 지정하고, 이동할 수 있으며, 작성한 메일은 보낼편지함에서 대기합니다. 로그아웃하면 모두 삭제됩니다.", "window_label": "보관할 기간", "window_description": "이보다 오래된 메일과 캘린더 일정은 이 기기에서 삭제됩니다. 온라인일 때는 계속 열어볼 수 있습니다.", "window_days": "{days, plural, other {#일}}", @@ -5792,7 +5792,7 @@ "clear_description": "이 기기에 보관된 이 계정의 데이터를 모두 삭제한 뒤 다시 다운로드합니다.", "clear_button": "비우기…", "clear_confirm_title": "이 기기에서 이 계정을 비울까요?", - "clear_confirm_message": "이 기기에 보관된 이 계정의 데이터가 모두 삭제되고 다시 다운로드됩니다. 서버에 있는 메일은 그대로 남습니다.", + "clear_confirm_message": "이 기기에 보관된 {account}의 데이터가 모두 삭제되고 다시 다운로드됩니다. 서버에 있는 메일은 그대로 남습니다.", "clear_confirm_unsent": "{count, plural, other {보내지 않은 메시지 #개가 사라집니다.}}", "clear_confirm": "비우기", "persist_refused": "저장 공간이 부족해지면 이 브라우저가 보관한 메일을 삭제할 수 있습니다. 앱을 설치하면 대개 막을 수 있습니다.", @@ -5816,7 +5816,13 @@ "kept_error_download": "다운로드하지 못함", "keep_offline": "오프라인용으로 보관", "unpin": "오프라인 보관 중지", - "unpin_label": "{name} 오프라인 보관 중지" + "unpin_label": "{name} 오프라인 보관 중지", + "warning_desktop": "이 컴퓨터의 사용자 계정을 쓸 수 있는 사람은 누구나 이 메일을 읽을 수 있습니다. 디스크 암호화를 사용하세요.", + "off_confirm_text": "이 기기에 보관된 {account}의 데이터가 모두 삭제됩니다. 서버에 있는 메일은 그대로 남습니다.", + "unsent_changes": "{count, plural, other {서버에 도달하지 못한 변경 사항 #개가 사라집니다.}}", + "clear_needs_connection": "모든 것을 다시 다운로드하므로 연결이 필요합니다.", + "usage_label": "이 기기에 보관된 항목", + "pin_not_ready": "이 파일은 아직 이 기기의 목록에 없습니다. 잠시 후 다시 시도하세요." }, "offlineDrafts": { "title": "이 기기의 임시 보관함", diff --git a/locales/lv/common.json b/locales/lv/common.json index d9aa9961a..72ac39998 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Pasta serveris neatbild. Mēģiniet vēlreiz pēc brīža." }, "offlineStorage": { - "keep_description": "Glabā jūsu e-pastu, kalendāru, kontaktpersonas un izvēlētos failus šifrētus šajā pārlūkā. Bezsaistē varat lasīt, atzīmēt un pārvietot vēstules, un tas, ko rakstāt, gaida izsūtnē. Izrakstoties viss tiek noņemts.", + "keep_description": "Glabā jūsu e-pastu, kalendāru, kontaktpersonas un izvēlētos failus šifrētus šajā ierīcē. Bezsaistē varat lasīt, atzīmēt un pārvietot vēstules, un tas, ko rakstāt, gaida izsūtnē. Izrakstoties viss tiek noņemts.", "window_label": "Cik tālu atpakaļ glabāt", "window_description": "Vecākās vēstules un kalendāra notikumi pazūd no šīs ierīces. Tās joprojām varat atvērt, kad ir savienojums.", "window_days": "{days, plural, zero {# dienu} one {# diena} other {# dienas}}", @@ -5792,7 +5792,7 @@ "clear_description": "Izdzēš visu, ko šī ierīce glabā šim kontam, un lejupielādē to no jauna.", "clear_button": "Notīrīt…", "clear_confirm_title": "Vai notīrīt šo kontu šajā ierīcē?", - "clear_confirm_message": "Viss, ko šī ierīce glabā šim kontam, tiek izdzēsts un lejupielādēts no jauna. Jūsu e-pasts serverī paliek.", + "clear_confirm_message": "Viss, ko šī ierīce glabā kontam {account}, tiek izdzēsts un lejupielādēts no jauna. Jūsu e-pasts serverī paliek.", "clear_confirm_unsent": "{count, plural, zero {# neizsūtītu vēstuļu tiks zaudēts.} one {Tiks zaudēta # neizsūtīta vēstule.} other {Tiks zaudētas # neizsūtītas vēstules.}}", "clear_confirm": "Notīrīt", "persist_refused": "Šis pārlūks var izdzēst glabāto e-pastu, kad pietrūkst vietas. Lietotnes instalēšana to parasti novērš.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Nevarēja lejupielādēt", "keep_offline": "Glabāt bezsaistē", "unpin": "Pārtraukt glabāt bezsaistē", - "unpin_label": "Pārtraukt glabāt {name} bezsaistē" + "unpin_label": "Pārtraukt glabāt {name} bezsaistē", + "warning_desktop": "Ikviens, kas var izmantot šī datora lietotāja kontu, var lasīt šo e-pastu. Izmantojiet diska šifrēšanu.", + "off_confirm_text": "Tas izdzēš visu, ko šī ierīce glabā kontam {account}. Jūsu e-pasts serverī paliek.", + "unsent_changes": "{count, plural, zero {# izmaiņu, kas nesasniedza serveri, tiks zaudēts.} one {Tiks zaudēta # izmaiņa, kas nesasniedza serveri.} other {Tiks zaudētas # izmaiņas, kas nesasniedza serveri.}}", + "clear_needs_connection": "Nepieciešams savienojums, jo viss tiek lejupielādēts no jauna.", + "usage_label": "Ko glabā šī ierīce", + "pin_not_ready": "Šī faila vēl nav šīs ierīces sarakstā. Mēģiniet vēlreiz pēc brīža." }, "offlineDrafts": { "title": "Melnraksti šajā ierīcē", diff --git a/locales/mn/common.json b/locales/mn/common.json index b0e0847f8..ebb31a650 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Шуудангийн сервер хариу өгөхгүй байна. Хэсэг хугацааны дараа дахин оролдоно уу." }, "offlineStorage": { - "keep_description": "Таны имэйл, хуанли, харилцагчид болон сонгосон файлуудыг энэ хөтөч дээр шифрлэн хадгална. Офлайн үед имэйл уншиж, тэмдэглэж, зөөж болох ба бичсэн зүйл тань гарах хайрцагт хүлээнэ. Гарахад бүгд устна.", + "keep_description": "Таны имэйл, хуанли, харилцагчид болон сонгосон файлуудыг энэ төхөөрөмж дээр шифрлэн хадгална. Офлайн үед имэйл уншиж, тэмдэглэж, зөөж болох ба бичсэн зүйл тань гарах хайрцагт хүлээнэ. Гарахад бүгд устна.", "window_label": "Хэр удаан хугацаагаар хадгалах вэ", "window_description": "Илүү хуучин имэйл болон хуанлийн үйл явдлууд энэ төхөөрөмжөөс устна. Холболттой үед та тэдгээрийг нээж болно.", "window_days": "{days, plural, one {# өдөр} other {# өдөр}}", @@ -5792,7 +5792,7 @@ "clear_description": "Энэ төхөөрөмж уг бүртгэлд зориулж хадгалсан бүхнийг устгаад дахин татна.", "clear_button": "Цэвэрлэх…", "clear_confirm_title": "Энэ бүртгэлийг энэ төхөөрөмж дээр цэвэрлэх үү?", - "clear_confirm_message": "Энэ төхөөрөмж уг бүртгэлд зориулж хадгалсан бүхэн устаж, дахин татагдана. Серверийн имэйл тань хэвээр үлдэнэ.", + "clear_confirm_message": "Энэ төхөөрөмж {account}-д зориулж хадгалсан бүхэн устаж, дахин татагдана. Серверийн имэйл тань хэвээр үлдэнэ.", "clear_confirm_unsent": "{count, plural, one {# илгээгээгүй мессеж алдагдана.} other {# илгээгээгүй мессеж алдагдана.}}", "clear_confirm": "Цэвэрлэх", "persist_refused": "Зай дутвал энэ хөтөч хадгалсан имэйлийг устгаж болно. Аппыг суулгавал ихэвчлэн ийм зүйл болдоггүй.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Татаж чадсангүй", "keep_offline": "Офлайн хадгалах", "unpin": "Офлайн хадгалахаа болих", - "unpin_label": "{name}-г офлайн хадгалахаа болих" + "unpin_label": "{name}-г офлайн хадгалахаа болих", + "warning_desktop": "Энэ компьютерийн хэрэглэгчийн бүртгэлийг ашиглаж чадах хэн ч энэ имэйлийг уншиж чадна. Дискний шифрлэлт ашиглаарай.", + "off_confirm_text": "Энэ нь төхөөрөмж {account}-д зориулж хадгалсан бүхнийг устгана. Серверийн имэйл тань хэвээр үлдэнэ.", + "unsent_changes": "{count, plural, one {Серверт хүрээгүй # өөрчлөлт алдагдана.} other {Серверт хүрээгүй # өөрчлөлт алдагдана.}}", + "clear_needs_connection": "Бүгдийг дахин татах тул холболт хэрэгтэй.", + "usage_label": "Энэ төхөөрөмж юу хадгалдаг вэ", + "pin_not_ready": "Энэ файл төхөөрөмжийн жагсаалтад хараахан ороогүй байна. Түр хүлээгээд дахин оролдоно уу." }, "offlineDrafts": { "title": "Энэ төхөөрөмж дээрх ноорог", diff --git a/locales/nb/common.json b/locales/nb/common.json index 2c62bd288..c41bee853 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "E-postserveren svarer ikke. Prøv igjen om litt." }, "offlineStorage": { - "keep_description": "Beholder e-posten, kalenderen, kontaktene og filene du velger, kryptert i denne nettleseren. Frakoblet kan du lese, flagge og flytte e-post, og det du skriver, venter i utboksen. Når du logger av, fjernes alt.", + "keep_description": "Beholder e-posten, kalenderen, kontaktene og filene du velger, kryptert på denne enheten. Frakoblet kan du lese, flagge og flytte e-post, og det du skriver, venter i utboksen. Når du logger av, fjernes alt.", "window_label": "Hvor langt tilbake som beholdes", "window_description": "Eldre e-post og kalenderhendelser forlater denne enheten. Du kan fortsatt åpne dem når du er tilkoblet.", "window_days": "{days, plural, one {# dag} other {# dager}}", @@ -5792,7 +5792,7 @@ "clear_description": "Sletter alt denne enheten beholder for denne kontoen, og laster det ned på nytt.", "clear_button": "Tøm…", "clear_confirm_title": "Tømme denne kontoen på denne enheten?", - "clear_confirm_message": "Alt denne enheten beholder for denne kontoen, slettes og lastes ned på nytt. E-posten din på serveren blir værende.", + "clear_confirm_message": "Alt denne enheten beholder for {account}, slettes og lastes ned på nytt. E-posten din på serveren blir værende.", "clear_confirm_unsent": "{count, plural, one {# usendt melding går tapt.} other {# usendte meldinger går tapt.}}", "clear_confirm": "Tøm", "persist_refused": "Denne nettleseren kan slette den beholdte e-posten når plassen blir knapp. Å installere appen hjelper vanligvis.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Kunne ikke lastes ned", "keep_offline": "Behold frakoblet", "unpin": "Slutt å beholde frakoblet", - "unpin_label": "Slutt å beholde {name} frakoblet" + "unpin_label": "Slutt å beholde {name} frakoblet", + "warning_desktop": "Alle som kan bruke brukerkontoen på denne datamaskinen, kan lese denne e-posten. Bruk diskkryptering.", + "off_confirm_text": "Dette sletter alt denne enheten beholder for {account}. E-posten din på serveren blir værende.", + "unsent_changes": "{count, plural, one {# endring som ikke har nådd serveren, går tapt.} other {# endringer som ikke har nådd serveren, går tapt.}}", + "clear_needs_connection": "Dette krever en tilkobling, fordi alt lastes ned på nytt.", + "usage_label": "Dette beholder enheten", + "pin_not_ready": "Denne filen er ikke på listen til denne enheten ennå. Prøv igjen om litt." }, "offlineDrafts": { "title": "Utkast på denne enheten", diff --git a/locales/nl/common.json b/locales/nl/common.json index 7cb5b803e..c60206d7a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "De mailserver antwoordt niet. Probeer het zo meteen opnieuw." }, "offlineStorage": { - "keep_description": "Bewaart je e-mail, agenda, contacten en gekozen bestanden versleuteld in deze browser. Offline kun je e-mail lezen, markeren en verplaatsen, en wat je schrijft wacht in Postvak UIT. Afmelden verwijdert alles.", + "keep_description": "Bewaart je e-mail, agenda, contacten en gekozen bestanden versleuteld op dit apparaat. Offline kun je e-mail lezen, markeren en verplaatsen, en wat je schrijft wacht in Postvak UIT. Afmelden verwijdert alles.", "window_label": "Hoe ver terug bewaren", "window_description": "Oudere e-mail en agenda-items verdwijnen van dit apparaat. Je kunt ze nog openen als je online bent.", "window_days": "{days, plural, one {# dag} other {# dagen}}", @@ -5792,7 +5792,7 @@ "clear_description": "Verwijdert alles wat dit apparaat voor dit account bewaart en downloadt het daarna opnieuw.", "clear_button": "Wissen…", "clear_confirm_title": "Dit account op dit apparaat wissen?", - "clear_confirm_message": "Alles wat dit apparaat voor dit account bewaart, wordt verwijderd en opnieuw gedownload. Je e-mail op de server blijft staan.", + "clear_confirm_message": "Alles wat dit apparaat voor {account} bewaart, wordt verwijderd en opnieuw gedownload. Je e-mail op de server blijft staan.", "clear_confirm_unsent": "{count, plural, one {# niet-verzonden bericht gaat verloren.} other {# niet-verzonden berichten gaan verloren.}}", "clear_confirm": "Wissen", "persist_refused": "Deze browser kan de bewaarde e-mail verwijderen als de ruimte opraakt. De app installeren voorkomt dat meestal.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Kon niet worden gedownload", "keep_offline": "Offline bewaren", "unpin": "Niet meer offline bewaren", - "unpin_label": "{name} niet meer offline bewaren" + "unpin_label": "{name} niet meer offline bewaren", + "warning_desktop": "Iedereen die dit gebruikersaccount op deze computer kan gebruiken, kan deze e-mail lezen. Gebruik schijfversleuteling.", + "off_confirm_text": "Hiermee wordt alles verwijderd wat dit apparaat voor {account} bewaart. Je e-mail op de server blijft staan.", + "unsent_changes": "{count, plural, one {# wijziging die de server niet heeft bereikt, gaat verloren.} other {# wijzigingen die de server niet hebben bereikt, gaan verloren.}}", + "clear_needs_connection": "Hiervoor is een verbinding nodig, omdat alles opnieuw wordt gedownload.", + "usage_label": "Wat dit apparaat bewaart", + "pin_not_ready": "Dit bestand staat nog niet op de lijst van dit apparaat. Probeer het zo meteen opnieuw." }, "offlineDrafts": { "title": "Concepten op dit apparaat", diff --git a/locales/pl/common.json b/locales/pl/common.json index a9a82b95c..b5f387c30 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Serwer poczty nie odpowiada. Spróbuj ponownie za chwilę." }, "offlineStorage": { - "keep_description": "Przechowuje Twoją pocztę, kalendarz, kontakty i wybrane pliki zaszyfrowane w tej przeglądarce. Offline możesz czytać, oznaczać i przenosić wiadomości, a to, co napiszesz, czeka w skrzynce nadawczej. Wylogowanie usuwa wszystko.", + "keep_description": "Przechowuje Twoją pocztę, kalendarz, kontakty i wybrane pliki zaszyfrowane na tym urządzeniu. Offline możesz czytać, oznaczać i przenosić wiadomości, a to, co napiszesz, czeka w skrzynce nadawczej. Wylogowanie usuwa wszystko.", "window_label": "Jak daleko wstecz przechowywać", "window_description": "Starsza poczta i wydarzenia z kalendarza znikają z tego urządzenia. Nadal możesz je otworzyć, gdy masz połączenie.", "window_days": "{days, plural, one {# dzień} few {# dni} many {# dni} other {# dnia}}", @@ -5792,7 +5792,7 @@ "clear_description": "Usuwa wszystko, co to urządzenie przechowuje dla tego konta, a potem pobiera to ponownie.", "clear_button": "Wyczyść…", "clear_confirm_title": "Wyczyścić to konto na tym urządzeniu?", - "clear_confirm_message": "Wszystko, co to urządzenie przechowuje dla tego konta, zostanie usunięte i pobrane ponownie. Twoja poczta na serwerze zostaje.", + "clear_confirm_message": "Wszystko, co to urządzenie przechowuje dla konta {account}, zostanie usunięte i pobrane ponownie. Twoja poczta na serwerze zostaje.", "clear_confirm_unsent": "{count, plural, one {# niewysłana wiadomość zostanie utracona.} few {# niewysłane wiadomości zostaną utracone.} many {# niewysłanych wiadomości zostanie utraconych.} other {# niewysłanej wiadomości zostanie utraconej.}}", "clear_confirm": "Wyczyść", "persist_refused": "Ta przeglądarka może usunąć przechowywaną pocztę, gdy zabraknie miejsca. Zainstalowanie aplikacji zwykle temu zapobiega.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Nie udało się pobrać", "keep_offline": "Przechowuj offline", "unpin": "Nie przechowuj już offline", - "unpin_label": "Nie przechowuj już {name} offline" + "unpin_label": "Nie przechowuj już {name} offline", + "warning_desktop": "Każdy, kto może korzystać z konta użytkownika na tym komputerze, może czytać tę pocztę. Użyj szyfrowania dysku.", + "off_confirm_text": "To usuwa wszystko, co to urządzenie przechowuje dla konta {account}. Twoja poczta na serwerze zostaje.", + "unsent_changes": "{count, plural, one {# zmiana, która nie dotarła do serwera, zostanie utracona.} few {# zmiany, które nie dotarły do serwera, zostaną utracone.} many {# zmian, które nie dotarły do serwera, zostanie utraconych.} other {# zmiany, która nie dotarła do serwera, zostanie utraconej.}}", + "clear_needs_connection": "Potrzebne jest połączenie, bo wszystko zostanie pobrane ponownie.", + "usage_label": "Co przechowuje to urządzenie", + "pin_not_ready": "Tego pliku nie ma jeszcze na liście tego urządzenia. Spróbuj ponownie za chwilę." }, "offlineDrafts": { "title": "Wersje robocze na tym urządzeniu", diff --git a/locales/pt/common.json b/locales/pt/common.json index 8eaa09aba..a47b9cd99 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "O servidor de correio não está a responder. Tente novamente daqui a pouco." }, "offlineStorage": { - "keep_description": "Mantém o seu correio, calendário, contactos e os ficheiros que escolher, cifrados neste navegador. Offline pode ler, sinalizar e mover e-mails, e o que escrever espera na caixa de saída. Terminar a sessão remove tudo.", + "keep_description": "Mantém o seu correio, calendário, contactos e os ficheiros que escolher, cifrados neste dispositivo. Offline pode ler, sinalizar e mover e-mails, e o que escrever espera na caixa de saída. Terminar a sessão remove tudo.", "window_label": "Quanto tempo atrás manter", "window_description": "Os e-mails e eventos mais antigos saem deste dispositivo. Pode continuar a abri-los quando tiver ligação.", "window_days": "{days, plural, one {# dia} other {# dias}}", @@ -5792,7 +5792,7 @@ "clear_description": "Elimina tudo o que este dispositivo mantém para esta conta e volta a transferi-lo.", "clear_button": "Limpar…", "clear_confirm_title": "Limpar esta conta neste dispositivo?", - "clear_confirm_message": "Tudo o que este dispositivo mantém para esta conta é eliminado e transferido novamente. O seu correio no servidor mantém-se.", + "clear_confirm_message": "Tudo o que este dispositivo mantém para {account} é eliminado e transferido novamente. O seu correio no servidor mantém-se.", "clear_confirm_unsent": "{count, plural, one {# mensagem por enviar será perdida.} other {# mensagens por enviar serão perdidas.}}", "clear_confirm": "Limpar", "persist_refused": "Este navegador pode eliminar o correio mantido quando o espaço escasseia. Instalar a aplicação costuma evitá-lo.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Não foi possível transferir", "keep_offline": "Manter offline", "unpin": "Deixar de manter offline", - "unpin_label": "Deixar de manter {name} offline" + "unpin_label": "Deixar de manter {name} offline", + "warning_desktop": "Qualquer pessoa que possa usar a conta de utilizador deste computador pode ler este correio. Use a cifragem do disco.", + "off_confirm_text": "Isto elimina tudo o que este dispositivo mantém para {account}. O seu correio no servidor mantém-se.", + "unsent_changes": "{count, plural, one {# alteração que não chegou ao servidor será perdida.} other {# alterações que não chegaram ao servidor serão perdidas.}}", + "clear_needs_connection": "É preciso ter ligação, porque tudo é transferido novamente.", + "usage_label": "O que este dispositivo mantém", + "pin_not_ready": "Este ficheiro ainda não está na lista deste dispositivo. Tente novamente dentro de instantes." }, "offlineDrafts": { "title": "Rascunhos neste dispositivo", diff --git a/locales/ro/common.json b/locales/ro/common.json index a50a742c9..a84e8ecf4 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Serverul de e-mail nu răspunde. Încercați din nou imediat." }, "offlineStorage": { - "keep_description": "Păstrează e-mailurile, calendarul, contactele și fișierele alese de dvs., criptate în acest browser. Offline puteți citi, marca și muta e-mailuri, iar ce scrieți așteaptă în Mesaje de trimis. La deconectare se șterge totul.", + "keep_description": "Păstrează e-mailurile, calendarul, contactele și fișierele alese de dvs., criptate pe acest dispozitiv. Offline puteți citi, marca și muta e-mailuri, iar ce scrieți așteaptă în Mesaje de trimis. La deconectare se șterge totul.", "window_label": "Cât de departe în urmă se păstrează", "window_description": "E-mailurile și evenimentele mai vechi dispar de pe acest dispozitiv. Le puteți deschide în continuare când sunteți online.", "window_days": "{days, plural, one {# zi} few {# zile} other {# de zile}}", @@ -5792,7 +5792,7 @@ "clear_description": "Șterge tot ce păstrează acest dispozitiv pentru acest cont, apoi descarcă din nou.", "clear_button": "Golește…", "clear_confirm_title": "Goliți acest cont pe acest dispozitiv?", - "clear_confirm_message": "Tot ce păstrează acest dispozitiv pentru acest cont este șters și descărcat din nou. E-mailurile dvs. de pe server rămân.", + "clear_confirm_message": "Tot ce păstrează acest dispozitiv pentru {account} este șters și descărcat din nou. E-mailurile dvs. de pe server rămân.", "clear_confirm_unsent": "{count, plural, one {# mesaj netrimis se pierde.} few {# mesaje netrimise se pierd.} other {# de mesaje netrimise se pierd.}}", "clear_confirm": "Golește", "persist_refused": "Acest browser poate șterge e-mailurile păstrate când se termină spațiul. Instalarea aplicației de obicei previne asta.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Nu a putut fi descărcat", "keep_offline": "Păstrează offline", "unpin": "Nu mai păstra offline", - "unpin_label": "Nu mai păstra {name} offline" + "unpin_label": "Nu mai păstra {name} offline", + "warning_desktop": "Oricine poate folosi contul de utilizator al acestui computer poate citi acest e-mail. Folosiți criptarea discului.", + "off_confirm_text": "Aceasta șterge tot ce păstrează acest dispozitiv pentru {account}. E-mailurile dvs. de pe server rămân.", + "unsent_changes": "{count, plural, one {# modificare care nu a ajuns pe server se pierde.} few {# modificări care nu au ajuns pe server se pierd.} other {# de modificări care nu au ajuns pe server se pierd.}}", + "clear_needs_connection": "Este nevoie de o conexiune, deoarece totul este descărcat din nou.", + "usage_label": "Ce păstrează acest dispozitiv", + "pin_not_ready": "Acest fișier nu se află încă în lista acestui dispozitiv. Încercați din nou peste un moment." }, "offlineDrafts": { "title": "Ciorne pe acest dispozitiv", diff --git a/locales/ru/common.json b/locales/ru/common.json index a9da6db0b..7ea9f08b6 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Почтовый сервер не отвечает. Повторите попытку через минуту." }, "offlineStorage": { - "keep_description": "Хранит вашу почту, календарь, контакты и выбранные файлы в зашифрованном виде в этом браузере. Без сети можно читать, помечать и перемещать письма, а написанное ждёт в исходящих. При выходе всё удаляется.", + "keep_description": "Хранит вашу почту, календарь, контакты и выбранные файлы в зашифрованном виде на этом устройстве. Без сети можно читать, помечать и перемещать письма, а написанное ждёт в исходящих. При выходе всё удаляется.", "window_label": "За какой период хранить", "window_description": "Более старые письма и события календаря удаляются с этого устройства. Их по-прежнему можно открыть, когда есть сеть.", "window_days": "{days, plural, one {# день} few {# дня} many {# дней} other {# дня}}", @@ -5792,7 +5792,7 @@ "clear_description": "Удаляет всё, что это устройство хранит для этой учётной записи, и загружает заново.", "clear_button": "Очистить…", "clear_confirm_title": "Очистить эту учётную запись на этом устройстве?", - "clear_confirm_message": "Всё, что это устройство хранит для этой учётной записи, будет удалено и загружено заново. Ваша почта на сервере останется.", + "clear_confirm_message": "Всё, что это устройство хранит для {account}, будет удалено и загружено заново. Ваша почта на сервере останется.", "clear_confirm_unsent": "{count, plural, one {# неотправленное письмо будет потеряно.} few {# неотправленных письма будут потеряны.} many {# неотправленных писем будут потеряны.} other {# неотправленного письма будет потеряно.}}", "clear_confirm": "Очистить", "persist_refused": "Этот браузер может удалить сохранённую почту, когда не хватает места. Установка приложения обычно это предотвращает.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Не удалось загрузить", "keep_offline": "Хранить без сети", "unpin": "Больше не хранить без сети", - "unpin_label": "Больше не хранить {name} без сети" + "unpin_label": "Больше не хранить {name} без сети", + "warning_desktop": "Любой, кто может пользоваться учётной записью на этом компьютере, может читать эту почту. Используйте шифрование диска.", + "off_confirm_text": "Будет удалено всё, что это устройство хранит для {account}. Ваша почта на сервере останется.", + "unsent_changes": "{count, plural, one {# изменение, не дошедшее до сервера, будет потеряно.} few {# изменения, не дошедшие до сервера, будут потеряны.} many {# изменений, не дошедших до сервера, будут потеряны.} other {# изменения, не дошедшего до сервера, будет потеряно.}}", + "clear_needs_connection": "Нужно подключение, потому что всё загружается заново.", + "usage_label": "Что хранит это устройство", + "pin_not_ready": "Этого файла ещё нет в списке этого устройства. Попробуйте ещё раз через минуту." }, "offlineDrafts": { "title": "Черновики на этом устройстве", diff --git a/locales/sk/common.json b/locales/sk/common.json index 365f3dfda..bd0c8e306 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Poštový server neodpovedá. Skúste to o chvíľu znova." }, "offlineStorage": { - "keep_description": "Uchováva vašu poštu, kalendár, kontakty a vybrané súbory zašifrované v tomto prehliadači. Offline môžete poštu čítať, označovať a presúvať a to, čo napíšete, čaká na odoslanie. Odhlásenie všetko odstráni.", + "keep_description": "Uchováva vašu poštu, kalendár, kontakty a vybrané súbory zašifrované v tomto zariadení. Offline môžete poštu čítať, označovať a presúvať a to, čo napíšete, čaká na odoslanie. Odhlásenie všetko odstráni.", "window_label": "Ako dlho do minulosti uchovávať", "window_description": "Staršia pošta a udalosti z kalendára toto zariadenie opustia. Keď budete online, môžete ich stále otvoriť.", "window_days": "{days, plural, one {# deň} few {# dni} many {# dňa} other {# dní}}", @@ -5792,7 +5792,7 @@ "clear_description": "Zmaže všetko, čo toto zariadenie pre tento účet uchováva, a stiahne to znova.", "clear_button": "Vymazať…", "clear_confirm_title": "Vymazať tento účet na tomto zariadení?", - "clear_confirm_message": "Všetko, čo toto zariadenie pre tento účet uchováva, sa zmaže a stiahne znova. Vaša pošta na serveri zostane.", + "clear_confirm_message": "Všetko, čo toto zariadenie uchováva pre účet {account}, sa zmaže a stiahne znova. Vaša pošta na serveri zostane.", "clear_confirm_unsent": "{count, plural, one {# neodoslaná správa sa stratí.} few {# neodoslané správy sa stratia.} many {# neodoslanej správy sa stratí.} other {# neodoslaných správ sa stratí.}}", "clear_confirm": "Vymazať", "persist_refused": "Tento prehliadač môže uchovanú poštu zmazať, keď dôjde miesto. Inštalácia aplikácie tomu zvyčajne zabráni.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Nepodarilo sa stiahnuť", "keep_offline": "Uchovať offline", "unpin": "Prestať uchovávať offline", - "unpin_label": "Prestať uchovávať {name} offline" + "unpin_label": "Prestať uchovávať {name} offline", + "warning_desktop": "Každý, kto môže používať používateľský účet na tomto počítači, môže túto poštu čítať. Použite šifrovanie disku.", + "off_confirm_text": "Tým sa zmaže všetko, čo toto zariadenie uchováva pre účet {account}. Vaša pošta na serveri zostane.", + "unsent_changes": "{count, plural, one {# zmena, ktorá sa nedostala na server, sa stratí.} few {# zmeny, ktoré sa nedostali na server, sa stratia.} many {# zmeny, ktorá sa nedostala na server, sa stratí.} other {# zmien, ktoré sa nedostali na server, sa stratí.}}", + "clear_needs_connection": "Je potrebné pripojenie, pretože sa všetko stiahne znova.", + "usage_label": "Čo toto zariadenie uchováva", + "pin_not_ready": "Tento súbor ešte nie je v zozname tohto zariadenia. Skúste to o chvíľu znova." }, "offlineDrafts": { "title": "Koncepty v tomto zariadení", diff --git a/locales/th/common.json b/locales/th/common.json index 600b9f8a0..1ec3a6997 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2109,7 +2109,7 @@ "server_not_answering": "เซิร์ฟเวอร์อีเมลไม่ตอบสนอง โปรดลองอีกครั้งในอีกสักครู่" }, "offlineStorage": { - "keep_description": "เก็บอีเมล ปฏิทิน รายชื่อติดต่อ และไฟล์ที่คุณเลือกไว้ในเบราว์เซอร์นี้แบบเข้ารหัส เมื่อออฟไลน์คุณอ่าน ติดธง และย้ายอีเมลได้ และสิ่งที่คุณเขียนจะรออยู่ในกล่องขาออก เมื่อออกจากระบบทุกอย่างจะถูกลบ", + "keep_description": "เก็บอีเมล ปฏิทิน รายชื่อติดต่อ และไฟล์ที่คุณเลือกไว้ในอุปกรณ์นี้แบบเข้ารหัส เมื่อออฟไลน์คุณอ่าน ติดธง และย้ายอีเมลได้ และสิ่งที่คุณเขียนจะรออยู่ในกล่องขาออก เมื่อออกจากระบบทุกอย่างจะถูกลบ", "window_label": "เก็บย้อนหลังนานแค่ไหน", "window_description": "อีเมลและกิจกรรมในปฏิทินที่เก่ากว่านี้จะถูกนำออกจากอุปกรณ์นี้ คุณยังเปิดดูได้เมื่อออนไลน์", "window_days": "{days, plural, other {# วัน}}", @@ -2137,7 +2137,7 @@ "clear_description": "ลบทุกอย่างที่อุปกรณ์นี้เก็บไว้สำหรับบัญชีนี้ แล้วดาวน์โหลดใหม่", "clear_button": "ล้าง…", "clear_confirm_title": "ล้างบัญชีนี้บนอุปกรณ์นี้ไหม", - "clear_confirm_message": "ทุกอย่างที่อุปกรณ์นี้เก็บไว้สำหรับบัญชีนี้จะถูกลบและดาวน์โหลดใหม่ อีเมลของคุณบนเซิร์ฟเวอร์ยังอยู่", + "clear_confirm_message": "ทุกอย่างที่อุปกรณ์นี้เก็บไว้สำหรับ {account} จะถูกลบและดาวน์โหลดใหม่ อีเมลของคุณบนเซิร์ฟเวอร์ยังอยู่", "clear_confirm_unsent": "{count, plural, other {ข้อความที่ยังไม่ได้ส่ง # ข้อความจะสูญหาย}}", "clear_confirm": "ล้าง", "persist_refused": "เบราว์เซอร์นี้อาจลบอีเมลที่เก็บไว้เมื่อพื้นที่เหลือน้อย การติดตั้งแอปมักช่วยป้องกันได้", @@ -2161,7 +2161,13 @@ "kept_error_download": "ดาวน์โหลดไม่ได้", "keep_offline": "เก็บไว้ใช้ออฟไลน์", "unpin": "เลิกเก็บไว้ใช้ออฟไลน์", - "unpin_label": "เลิกเก็บ {name} ไว้ใช้ออฟไลน์" + "unpin_label": "เลิกเก็บ {name} ไว้ใช้ออฟไลน์", + "warning_desktop": "ทุกคนที่ใช้บัญชีผู้ใช้บนคอมพิวเตอร์เครื่องนี้ได้จะอ่านอีเมลนี้ได้ ควรใช้การเข้ารหัสดิสก์", + "off_confirm_text": "การดำเนินการนี้จะลบทุกอย่างที่อุปกรณ์นี้เก็บไว้สำหรับ {account} อีเมลของคุณบนเซิร์ฟเวอร์ยังอยู่", + "unsent_changes": "{count, plural, other {การเปลี่ยนแปลงที่ยังไปไม่ถึงเซิร์ฟเวอร์ # รายการจะสูญหาย}}", + "clear_needs_connection": "ต้องมีการเชื่อมต่อ เพราะทุกอย่างจะถูกดาวน์โหลดใหม่", + "usage_label": "สิ่งที่อุปกรณ์นี้เก็บไว้", + "pin_not_ready": "ไฟล์นี้ยังไม่อยู่ในรายการของอุปกรณ์นี้ ลองอีกครั้งในอีกสักครู่" }, "offlineDrafts": { "title": "ฉบับร่างในอุปกรณ์นี้", diff --git a/locales/tr/common.json b/locales/tr/common.json index 3ceb48445..ec40e5c20 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "E-posta sunucusu yanıt vermiyor. Birazdan yeniden deneyin." }, "offlineStorage": { - "keep_description": "E-postalarınızı, takviminizi, kişilerinizi ve seçtiğiniz dosyaları bu tarayıcıda şifreli olarak tutar. Çevrimdışıyken e-postaları okuyabilir, işaretleyebilir ve taşıyabilirsiniz; yazdıklarınız giden kutusunda bekler. Oturumu kapatınca hepsi silinir.", + "keep_description": "E-postalarınızı, takviminizi, kişilerinizi ve seçtiğiniz dosyaları bu cihazda şifreli olarak tutar. Çevrimdışıyken e-postaları okuyabilir, işaretleyebilir ve taşıyabilirsiniz; yazdıklarınız giden kutusunda bekler. Oturumu kapatınca hepsi silinir.", "window_label": "Ne kadar geriye tutulsun", "window_description": "Daha eski e-postalar ve takvim etkinlikleri bu cihazdan kaldırılır. Bağlantınız olduğunda yine açabilirsiniz.", "window_days": "{days, plural, one {# gün} other {# gün}}", @@ -5792,7 +5792,7 @@ "clear_description": "Bu cihazın bu hesap için tuttuğu her şeyi siler, sonra yeniden indirir.", "clear_button": "Temizle…", "clear_confirm_title": "Bu hesap bu cihazda temizlensin mi?", - "clear_confirm_message": "Bu cihazın bu hesap için tuttuğu her şey silinir ve yeniden indirilir. Sunucudaki e-postalarınız kalır.", + "clear_confirm_message": "Bu cihazın {account} için tuttuğu her şey silinir ve yeniden indirilir. Sunucudaki e-postalarınız kalır.", "clear_confirm_unsent": "{count, plural, one {# gönderilmemiş ileti kaybolacak.} other {# gönderilmemiş ileti kaybolacak.}}", "clear_confirm": "Temizle", "persist_refused": "Alan azaldığında bu tarayıcı tutulan e-postaları silebilir. Uygulamayı yüklemek genellikle bunu önler.", @@ -5816,7 +5816,13 @@ "kept_error_download": "İndirilemedi", "keep_offline": "Çevrimdışı tut", "unpin": "Çevrimdışı tutmayı bırak", - "unpin_label": "{name} dosyasını çevrimdışı tutmayı bırak" + "unpin_label": "{name} dosyasını çevrimdışı tutmayı bırak", + "warning_desktop": "Bu bilgisayarın kullanıcı hesabını kullanabilen herkes bu e-postaları okuyabilir. Disk şifrelemesini kullanın.", + "off_confirm_text": "Bu işlem, bu cihazın {account} için tuttuğu her şeyi siler. Sunucudaki e-postalarınız kalır.", + "unsent_changes": "{count, plural, one {Sunucuya ulaşmamış # değişiklik kaybolacak.} other {Sunucuya ulaşmamış # değişiklik kaybolacak.}}", + "clear_needs_connection": "Her şey yeniden indirileceği için bağlantı gerekir.", + "usage_label": "Bu cihazın tuttukları", + "pin_not_ready": "Bu dosya henüz bu cihazın listesinde değil. Biraz sonra yeniden deneyin." }, "offlineDrafts": { "title": "Bu cihazdaki taslaklar", diff --git a/locales/uk/common.json b/locales/uk/common.json index fb286e371..c85a7b154 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "Поштовий сервер не відповідає. Спробуйте ще раз за хвилину." }, "offlineStorage": { - "keep_description": "Зберігає вашу пошту, календар, контакти та вибрані файли в зашифрованому вигляді в цьому браузері. Без мережі можна читати, позначати й переміщати листи, а написане чекає у вихідних. Під час виходу все видаляється.", + "keep_description": "Зберігає вашу пошту, календар, контакти та вибрані файли в зашифрованому вигляді на цьому пристрої. Без мережі можна читати, позначати й переміщати листи, а написане чекає у вихідних. Під час виходу все видаляється.", "window_label": "За який період зберігати", "window_description": "Старіші листи й події календаря видаляються з цього пристрою. Їх і надалі можна відкрити, коли є мережа.", "window_days": "{days, plural, one {# день} few {# дні} many {# днів} other {# дня}}", @@ -5792,7 +5792,7 @@ "clear_description": "Видаляє все, що цей пристрій зберігає для цього облікового запису, і завантажує знову.", "clear_button": "Очистити…", "clear_confirm_title": "Очистити цей обліковий запис на цьому пристрої?", - "clear_confirm_message": "Усе, що цей пристрій зберігає для цього облікового запису, буде видалено й завантажено знову. Ваша пошта на сервері залишиться.", + "clear_confirm_message": "Усе, що цей пристрій зберігає для {account}, буде видалено й завантажено знову. Ваша пошта на сервері залишиться.", "clear_confirm_unsent": "{count, plural, one {# невідправлений лист буде втрачено.} few {# невідправлені листи буде втрачено.} many {# невідправлених листів буде втрачено.} other {# невідправленого листа буде втрачено.}}", "clear_confirm": "Очистити", "persist_refused": "Цей браузер може видалити збережену пошту, коли бракує місця. Встановлення застосунку зазвичай цьому запобігає.", @@ -5816,7 +5816,13 @@ "kept_error_download": "Не вдалося завантажити", "keep_offline": "Зберігати без мережі", "unpin": "Більше не зберігати без мережі", - "unpin_label": "Більше не зберігати {name} без мережі" + "unpin_label": "Більше не зберігати {name} без мережі", + "warning_desktop": "Будь-хто, хто може користуватися обліковим записом на цьому комп'ютері, може читати цю пошту. Використовуйте шифрування диска.", + "off_confirm_text": "Буде видалено все, що цей пристрій зберігає для {account}. Ваша пошта на сервері залишиться.", + "unsent_changes": "{count, plural, one {# зміну, що не дійшла до сервера, буде втрачено.} few {# зміни, що не дійшли до сервера, буде втрачено.} many {# змін, що не дійшли до сервера, буде втрачено.} other {# зміни, що не дійшла до сервера, буде втрачено.}}", + "clear_needs_connection": "Потрібне з'єднання, бо все завантажується знову.", + "usage_label": "Що зберігає цей пристрій", + "pin_not_ready": "Цього файла ще немає у списку цього пристрою. Спробуйте ще раз за хвилину." }, "offlineDrafts": { "title": "Чернетки на цьому пристрої", diff --git a/locales/vi/common.json b/locales/vi/common.json index 41282e782..c58e2cdc7 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5776,7 +5776,7 @@ "server_not_answering": "Máy chủ thư không phản hồi. Hãy thử lại sau giây lát." }, "offlineStorage": { - "keep_description": "Giữ thư, lịch, danh bạ và các tệp bạn chọn, được mã hóa trong trình duyệt này. Khi ngoại tuyến bạn có thể đọc, gắn cờ và di chuyển thư, còn thư bạn viết sẽ chờ trong Hộp thư đi. Khi đăng xuất, tất cả sẽ bị xóa.", + "keep_description": "Giữ thư, lịch, danh bạ và các tệp bạn chọn, được mã hóa trên thiết bị này. Khi ngoại tuyến bạn có thể đọc, gắn cờ và di chuyển thư, còn thư bạn viết sẽ chờ trong Hộp thư đi. Khi đăng xuất, tất cả sẽ bị xóa.", "window_label": "Giữ lại bao lâu", "window_description": "Thư và sự kiện lịch cũ hơn sẽ bị xóa khỏi thiết bị này. Bạn vẫn có thể mở chúng khi có kết nối.", "window_days": "{days, plural, other {# ngày}}", @@ -5804,7 +5804,7 @@ "clear_description": "Xóa mọi thứ thiết bị này giữ cho tài khoản này, rồi tải lại.", "clear_button": "Xóa…", "clear_confirm_title": "Xóa tài khoản này trên thiết bị này?", - "clear_confirm_message": "Mọi thứ thiết bị này giữ cho tài khoản này sẽ bị xóa và tải lại. Thư của bạn trên máy chủ vẫn còn.", + "clear_confirm_message": "Mọi thứ thiết bị này giữ cho {account} sẽ bị xóa và tải lại. Thư của bạn trên máy chủ vẫn còn.", "clear_confirm_unsent": "{count, plural, other {# thư chưa gửi sẽ bị mất.}}", "clear_confirm": "Xóa", "persist_refused": "Trình duyệt này có thể xóa thư đã giữ khi sắp hết dung lượng. Cài đặt ứng dụng thường giúp tránh điều này.", @@ -5828,7 +5828,13 @@ "kept_error_download": "Không tải xuống được", "keep_offline": "Giữ ngoại tuyến", "unpin": "Ngừng giữ ngoại tuyến", - "unpin_label": "Ngừng giữ {name} ngoại tuyến" + "unpin_label": "Ngừng giữ {name} ngoại tuyến", + "warning_desktop": "Bất kỳ ai dùng được tài khoản người dùng trên máy tính này đều có thể đọc thư này. Hãy dùng mã hóa ổ đĩa.", + "off_confirm_text": "Thao tác này xóa mọi thứ thiết bị này giữ cho {account}. Thư của bạn trên máy chủ vẫn còn.", + "unsent_changes": "{count, plural, other {# thay đổi chưa đến được máy chủ sẽ bị mất.}}", + "clear_needs_connection": "Cần có kết nối, vì mọi thứ sẽ được tải lại.", + "usage_label": "Những gì thiết bị này giữ", + "pin_not_ready": "Tệp này chưa có trong danh sách của thiết bị này. Hãy thử lại sau giây lát." }, "offlineDrafts": { "title": "Bản nháp trên thiết bị này", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index b3310374d..67bbafae1 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "郵件伺服器沒有回應。請稍後再試。" }, "offlineStorage": { - "keep_description": "在此瀏覽器中加密保留你的郵件、行事曆、聯絡人和你選擇的檔案。離線時可以閱讀、標記和移動郵件,寫好的郵件會在寄件匣中等待。登出時會全部刪除。", + "keep_description": "在此裝置上加密保留你的郵件、行事曆、聯絡人和你選擇的檔案。離線時可以閱讀、標記和移動郵件,寫好的郵件會在寄件匣中等待。登出時會全部刪除。", "window_label": "保留多久以內的內容", "window_description": "更早的郵件和行事曆活動會從此裝置移除。連線時你仍可以開啟它們。", "window_days": "{days, plural, other {# 天}}", @@ -5792,7 +5792,7 @@ "clear_description": "刪除此裝置為該帳號保留的全部內容,然後重新下載。", "clear_button": "清除…", "clear_confirm_title": "在此裝置上清除該帳號?", - "clear_confirm_message": "此裝置為該帳號保留的全部內容會被刪除並重新下載。伺服器上的郵件不受影響。", + "clear_confirm_message": "此裝置為 {account} 保留的全部內容會被刪除並重新下載。伺服器上的郵件不受影響。", "clear_confirm_unsent": "{count, plural, other {# 封未寄出的郵件將會遺失。}}", "clear_confirm": "清除", "persist_refused": "儲存空間不足時,此瀏覽器可能會刪除保留的郵件。安裝應用程式通常可以避免這種情況。", @@ -5816,7 +5816,13 @@ "kept_error_download": "無法下載", "keep_offline": "離線保留", "unpin": "不再離線保留", - "unpin_label": "不再離線保留 {name}" + "unpin_label": "不再離線保留 {name}", + "warning_desktop": "任何能使用這台電腦使用者帳號的人都可以閱讀這些郵件。請使用磁碟加密。", + "off_confirm_text": "這會刪除此裝置為 {account} 保留的全部內容。伺服器上的郵件不受影響。", + "unsent_changes": "{count, plural, other {# 項尚未送達伺服器的變更將會遺失。}}", + "clear_needs_connection": "需要連線,因為所有內容都會重新下載。", + "usage_label": "此裝置保留的內容", + "pin_not_ready": "此檔案還不在此裝置的清單中。請稍後再試。" }, "offlineDrafts": { "title": "此裝置上的草稿", diff --git a/locales/zh/common.json b/locales/zh/common.json index 4693f1f58..4897cae00 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5764,7 +5764,7 @@ "server_not_answering": "邮件服务器没有响应。请稍后再试。" }, "offlineStorage": { - "keep_description": "在此浏览器中加密保留你的邮件、日历、联系人和你选择的文件。离线时可以阅读、标记和移动邮件,写好的邮件会在发件箱中等待。退出登录时会全部删除。", + "keep_description": "在此设备上加密保留你的邮件、日历、联系人和你选择的文件。离线时可以阅读、标记和移动邮件,写好的邮件会在发件箱中等待。退出登录时会全部删除。", "window_label": "保留多久以内的内容", "window_description": "更早的邮件和日历事件会从此设备中移除。联网时你仍可以打开它们。", "window_days": "{days, plural, other {# 天}}", @@ -5792,7 +5792,7 @@ "clear_description": "删除此设备为该账户保留的全部内容,然后重新下载。", "clear_button": "清除…", "clear_confirm_title": "在此设备上清除该账户?", - "clear_confirm_message": "此设备为该账户保留的全部内容会被删除并重新下载。服务器上的邮件不受影响。", + "clear_confirm_message": "此设备为 {account} 保留的全部内容会被删除并重新下载。服务器上的邮件不受影响。", "clear_confirm_unsent": "{count, plural, other {# 封未发送的邮件将丢失。}}", "clear_confirm": "清除", "persist_refused": "存储空间不足时,此浏览器可能会删除保留的邮件。安装应用通常可以避免这种情况。", @@ -5816,7 +5816,13 @@ "kept_error_download": "无法下载", "keep_offline": "离线保留", "unpin": "不再离线保留", - "unpin_label": "不再离线保留 {name}" + "unpin_label": "不再离线保留 {name}", + "warning_desktop": "任何能使用这台电脑用户账户的人都可以阅读这些邮件。请使用磁盘加密。", + "off_confirm_text": "这会删除此设备为 {account} 保留的全部内容。服务器上的邮件不受影响。", + "unsent_changes": "{count, plural, other {# 项尚未到达服务器的更改将丢失。}}", + "clear_needs_connection": "需要联网,因为所有内容都会重新下载。", + "usage_label": "此设备保留的内容", + "pin_not_ready": "此文件还不在此设备的列表中。请稍后再试。" }, "offlineDrafts": { "title": "此设备上的草稿", diff --git a/stores/__tests__/policy-store-partial.test.ts b/stores/__tests__/policy-store-partial.test.ts index bf40f6ac9..e22ce8110 100644 --- a/stores/__tests__/policy-store-partial.test.ts +++ b/stores/__tests__/policy-store-partial.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Before signing in the server answers with the public part of the policy; // the store has to fetch the rest once a session exists. @@ -20,6 +20,33 @@ beforeEach(() => { usePolicyStore.setState({ policy: { ...DEFAULT_POLICY }, loaded: false, partial: false }); }); +describe('a public answer to a page that is signed in', () => { + const FULL = { ...DEFAULT_POLICY, organisation: { devices: { noOffline: true } } }; + + afterEach(() => localStorage.removeItem('auth-storage')); + + it('doesn\'t replace the whole policy it has: the public part has no caps', async () => { + localStorage.setItem('auth-storage', JSON.stringify({ state: { isAuthenticated: true } })); + fetchMock.mockResolvedValueOnce(answer(FULL)); + await usePolicyStore.getState().fetchPolicy(); + expect(usePolicyStore.getState().policy.organisation?.devices.noOffline).toBe(true); + + fetchMock.mockResolvedValueOnce(answer({ ...DEFAULT_POLICY }, 'public')); + await usePolicyStore.getState().fetchPolicy(); + expect(usePolicyStore.getState().partial).toBe(false); + expect(usePolicyStore.getState().policy.organisation?.devices.noOffline).toBe(true); + }); + + it('does once nobody is signed in', async () => { + fetchMock.mockResolvedValueOnce(answer(FULL)); + await usePolicyStore.getState().fetchPolicy(); + fetchMock.mockResolvedValueOnce(answer({ ...DEFAULT_POLICY }, 'public')); + await usePolicyStore.getState().fetchPolicy(); + expect(usePolicyStore.getState().partial).toBe(true); + expect(usePolicyStore.getState().policy.organisation?.devices.noOffline).toBeFalsy(); + }); +}); + describe('policy store', () => { it('fetches the full policy after signing in when only the public part was loaded', async () => { fetchMock.mockResolvedValueOnce(answer({ ...DEFAULT_POLICY }, 'public')); diff --git a/stores/policy-store.ts b/stores/policy-store.ts index ece536085..df2e1e577 100644 --- a/stores/policy-store.ts +++ b/stores/policy-store.ts @@ -157,6 +157,15 @@ function resolved(rawPolicy: SettingsPolicy, subject: PolicySubject | null) { const INITIAL = resolved({ ...DEFAULT_POLICY }, null); +/** The session auth-store keeps, read without importing it. */ +function persistedSignedIn(): boolean { + try { + return JSON.parse(localStorage.getItem('auth-storage') ?? 'null')?.state?.isAuthenticated === true; + } catch { + return false; + } +} + export const usePolicyStore = create()((set, get) => ({ ...INITIAL, subject: null, @@ -216,6 +225,12 @@ export const usePolicyStore = create()((set, get) => ({ set({ loaded: true }); return; } + // A signed-in page that got the public part (its cookie wasn't there yet) + // keeps the whole policy it has: the public part has no caps and no scopes. + if (partial && !current.partial && (current.source === 'server' || current.source === 'cache') && persistedSignedIn()) { + set({ loaded: true }); + return; + } lastServerJson = json; set({ ...resolved(IS_LITE ? applyLitePolicy(data) : data, get().subject), loaded: true, source: 'server', partial, scopeDomains }); if (!partial) writeCachedPolicy(data); diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index c3f90fa99..d5214d78c 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -128,7 +128,7 @@ export interface ProTab { * localStorage would give it away. The tab's body puts the title back when it * has read the message. */ -const TITLE_UNTIL_LOADED = '…'; +export const TITLE_UNTIL_LOADED = '…'; function withoutSubjects(tabs: ProTab[]): ProTab[] { if (useOfflineStore.getState().enabled.length === 0) return tabs; @@ -687,7 +687,9 @@ export const useProTabStore = create()( updateTabTitle: (id, title) => { set({ - tabs: get().tabs.map((tab) => (tab.id === id ? { ...tab, title } : tab)), + tabs: get().tabs.map((tab) => (tab.id === id + ? { ...tab, title, ...(tab.emailData ? { emailData: { ...tab.emailData, title } } : {}) } + : tab)), }); }, From 1c6720c3a8fbd8d50fb866907803d095979bcb79 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:40:28 +0100 Subject: [PATCH 28/35] test: give the wipe test a subject and count waiting changes per account Both follow the review's fixes: a policy without a subject no longer decides, and the fallback count of changes is per account. --- hooks/use-keep-file-offline.ts | 2 +- lib/offline/__tests__/outbox.test.ts | 1 + lib/offline/__tests__/review-fixes.test.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/hooks/use-keep-file-offline.ts b/hooks/use-keep-file-offline.ts index 31521bec3..efcec38ad 100644 --- a/hooks/use-keep-file-offline.ts +++ b/hooks/use-keep-file-offline.ts @@ -30,7 +30,7 @@ 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 own = client?.getAccountId?.() ?? ''; const keptOffline = useMemo(() => new Set(pinned.map((file) => (file.jmap === own ? file.id : `${file.jmap}:${file.id}`))), [pinned, own]); diff --git a/lib/offline/__tests__/outbox.test.ts b/lib/offline/__tests__/outbox.test.ts index 1ce7a5c4c..64579f533 100644 --- a/lib/offline/__tests__/outbox.test.ts +++ b/lib/offline/__tests__/outbox.test.ts @@ -426,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([])); diff --git a/lib/offline/__tests__/review-fixes.test.ts b/lib/offline/__tests__/review-fixes.test.ts index c7e4579e5..451caa937 100644 --- a/lib/offline/__tests__/review-fixes.test.ts +++ b/lib/offline/__tests__/review-fixes.test.ts @@ -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 }); From cd3bd333072508145f8238c2d65d4cdccf2db09f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:07:59 +0100 Subject: [PATCH 29/35] fix: bring an account back when its server is listed again, whichever way the page got to the login --- app/(main)/[locale]/login/page.tsx | 14 ++++++++++---- stores/auth-store.ts | 16 ++++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 1b81ea206..bd29046e7 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -4,6 +4,7 @@ import { Suspense, useState, useEffect, useRef, useCallback, useId, useMemo } fr import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag"; import { forgetDesktopServer, getDesktopBridge } from "@/lib/desktop/bridge"; import { customServerAllowedByDesktopPolicy, jmapServerAllowedByDesktopPolicy, useDesktopPolicy } from "@/lib/desktop/policy"; +import { accountRefusedByDesktopPolicy } from "@/lib/desktop/refused-accounts"; import { browserSignInAvailable, runDesktopBrowserSignIn } from "@/lib/desktop/browser-sign-in"; import { useRouter } from "@/i18n/navigation"; import { useParams, useSearchParams } from "next/navigation"; @@ -201,10 +202,15 @@ function LoginPageContent() { const params = useParams(); const searchParams = useSearchParams(); const isAddAccountMode = searchParams.get("mode") === "add-account"; - // Also when the app sent someone here because their server came off the list. - const [desktopPolicyNotice, setDesktopPolicyNotice] = useState( - searchParams.get("notice") === "server_refused" ? tDesktopPolicy("server_refused") : null, - ); + const [desktopPolicyNotice, setDesktopPolicyNotice] = useState(null); + // Whichever way someone got here: every account they have is on a server the device's + // OS policy refuses for now. They wait, and come back when it's allowed again. + useEffect(() => { + const stored = useAccountStore.getState().accounts; + if (stored.length > 0 && stored.every((account) => accountRefusedByDesktopPolicy(account, desktopPolicy))) { + setDesktopPolicyNotice(tDesktopPolicy("server_refused")); + } + }, [desktopPolicy, tDesktopPolicy]); // When the mobile app launches the webmail in a browser tab it tacks on // these params. We grab them once at mount and stash them in a ref so any diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 6307b1e0d..5b10e2b78 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -3682,10 +3682,12 @@ function leaveForRefusedServer(): void { closePopoutWindow(); return; } - replaceWindowLocation(`${getLocaleLoginPath()}?notice=server_refused`); + replaceWindowLocation(getLocaleLoginPath()); } -const stoppedForPolicy = new Set(); +// What's refused now, as of this page's start too: a reload to the login page mustn't +// forget that an account is waiting, or it wouldn't come back when it's allowed again. +let refusedAccounts = new Set(); /** * The device's OS policy took a server off its list, or put one back. A refused account @@ -3698,7 +3700,6 @@ export function applyDesktopServerPolicy(): void { const accounts = useAccountStore.getState().accounts; const plan = planForRefusedAccounts(accounts, auth.activeAccountId, getDesktopPolicy()); for (const id of plan.stop) { - stoppedForPolicy.add(id); clearRefreshTimer(id); const client = clients.get(id); if (client) { @@ -3708,8 +3709,8 @@ export function applyDesktopServerPolicy(): void { } useAccountStore.getState().updateAccount(id, { isConnected: false }); } - const returned = [...stoppedForPolicy].filter((id) => !plan.refused.includes(id)); - for (const id of returned) stoppedForPolicy.delete(id); + const returned = [...refusedAccounts].filter((id) => !plan.refused.includes(id)); + refusedAccounts = new Set(plan.refused); if (plan.activeRefused) { if (plan.next) void auth.switchAccount(plan.next); @@ -3724,7 +3725,10 @@ export function applyDesktopServerPolicy(): void { if (returned.length > 0 && !auth.client) window.location.replace('/'); } -if (typeof window !== 'undefined') onDesktopPolicyChange(applyDesktopServerPolicy); +if (typeof window !== 'undefined') { + refusedAccounts = new Set(planForRefusedAccounts(useAccountStore.getState().accounts, null, getDesktopPolicy()).refused); + onDesktopPolicyChange(applyDesktopServerPolicy); +} // See canEnterLoginInPlace. let signedInSincePageLoad = useAuthStore.getState().isAuthenticated; From 832775a392bfd1e30a18133ba7797d87b1484e2d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:24:10 +0100 Subject: [PATCH 30/35] fix: render an encrypted message once per opening, and let its plugin keep external content off A plugin's render can be a key operation, and mark-read replacing the message object ran it twice: a second PIN dialog, and two cancels lock the key. The effects now depend on what the hook sees and on the explicit re-render nonce, not on the object. The list row and the full message still differ, as a row has no headers for a plugin to recognise its mail by. onRenderEmailBody can return `blockExternalContent: true` (flag `api.host.features.blockExternalContent`) for a body it can't vouch for. External content then stays off whatever the settings and trusted senders allow, in the reader and in conversations, with no way to load it. The S/MIME plugin uses it against EFAIL. --- .../__tests__/plugin-render-once.test.tsx | 224 ++++++++++++++++++ components/email/email-viewer.tsx | 24 +- components/email/thread-conversation-view.tsx | 6 +- hooks/use-plugin-rendered-body.ts | 36 ++- lib/plugin-sandbox/host-features.ts | 2 + lib/plugin-types.ts | 6 + 6 files changed, 288 insertions(+), 10 deletions(-) create mode 100644 components/email/__tests__/plugin-render-once.test.tsx diff --git a/components/email/__tests__/plugin-render-once.test.tsx b/components/email/__tests__/plugin-render-once.test.tsx new file mode 100644 index 000000000..9c70539cd --- /dev/null +++ b/components/email/__tests__/plugin-render-once.test.tsx @@ -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 = /]*\ssrc="https:\/\/tracker/; +const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[]; + +const message = (over: Partial = {}) => ({ + 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) => ; + +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: '

    Body

    ', 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: `

    Secret body

    `, 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 = () => {}) => ( + + ); + + it('keeps external content off when the plugin says so, even with it allowed', async () => { + renderHooks.onRenderEmailBody.register('smime', (async () => ({ + html: `

    Secret body

    `, 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: `

    Secret body

    `, 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); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index e75a260ca..4f431dd60 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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(null); const [pluginRenderedText, setPluginRenderedText] = useState(null); const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState(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')) && (
    diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 98e5b407c..eca991274 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -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(null); const [plainBodyEl, setPlainBodyEl] = useState(null); @@ -930,7 +932,7 @@ export function ConversationEmailBody({
    {phishingMarked && } {/* External content warning */} - {hasBlockedContent && !allowExternal && emailContent.isHtml && ( + {hasBlockedContent && !allowExternal && !pluginBody.blockExternal && emailContent.isHtml && (
    {remoteBlockedByOrg ? t("third_party.remote_content_blocked") : t("email_viewer.external_content_warning")} diff --git a/hooks/use-plugin-rendered-body.ts b/hooks/use-plugin-rendered-body.ts index 34bdce248..0bc4a7756 100644 --- a/hooks/use-plugin-rendered-body.ts +++ b/hooks/use-plugin-rendered-body.ts @@ -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; + /** 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(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; } diff --git a/lib/plugin-sandbox/host-features.ts b/lib/plugin-sandbox/host-features.ts index 50deeb4eb..bdc8b1840 100644 --- a/lib/plugin-sandbox/host-features.ts +++ b/lib/plugin-sandbox/host-features.ts @@ -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 } : {}), }; } diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 520c594d8..c72ebd2d7 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -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. */ From a74543c1378235d23446421425d8a3dc63481ae9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:24:11 +0100 Subject: [PATCH 31/35] feat: take back certificate grants at sign-out and account switch --- lib/__tests__/plugin-keystore.test.ts | 11 +++++ lib/desktop/keystore-grants.ts | 11 +++++ lib/desktop/types.ts | 2 + .../__tests__/auth-store-logout-hooks.test.ts | 41 +++++++++++++++++++ stores/auth-store.ts | 4 ++ 5 files changed, 69 insertions(+) create mode 100644 lib/desktop/keystore-grants.ts diff --git a/lib/__tests__/plugin-keystore.test.ts b/lib/__tests__/plugin-keystore.test.ts index 3a1426d92..6820a7858 100644 --- a/lib/__tests__/plugin-keystore.test.ts +++ b/lib/__tests__/plugin-keystore.test.ts @@ -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' }; diff --git a/lib/desktop/keystore-grants.ts b/lib/desktop/keystore-grants.ts new file mode 100644 index 000000000..0a7ad76a4 --- /dev/null +++ b/lib/desktop/keystore-grants.ts @@ -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(() => {}); +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 5c60da8bf..e577f2036 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -180,6 +180,8 @@ export interface DesktopKeystoreBridge { accessState: () => Promise; /** Takes back a stored yes and this session's grants. Only ever narrows. */ revokeAccess: () => Promise; + /** Takes back this session's grants, for the next account. Absent on older shells. */ + clearGrants?: () => Promise; } export interface BulwarkDesktopBridge { diff --git a/stores/__tests__/auth-store-logout-hooks.test.ts b/stores/__tests__/auth-store-logout-hooks.test.ts index 810e90e93..5deaf2b14 100644 --- a/stores/__tests__/auth-store-logout-hooks.test.ts +++ b/stores/__tests__/auth-store-logout-hooks.test.ts @@ -33,6 +33,47 @@ describe('sign-out with plugins', () => { expect(after).toHaveBeenCalledWith({ accountId: 'all' }); }); + describe('keys in the OS store', () => { + afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; }); + const withBridge = (clearGrants?: () => Promise) => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, keystore: { clearGrants } }; + }; + + it('takes back this session\'s certificate grants at sign-out', async () => { + const clearGrants = vi.fn().mockResolvedValue(undefined); + withBridge(clearGrants); + await useAuthStore.getState().logout(); + expect(clearGrants).toHaveBeenCalledTimes(1); + }); + + it('does so when all accounts sign out too', async () => { + const clearGrants = vi.fn().mockResolvedValue(undefined); + withBridge(clearGrants); + await useAuthStore.getState().logoutAll(); + expect(clearGrants).toHaveBeenCalled(); + }); + + it('and when switching to another account', () => { + const clearGrants = vi.fn().mockResolvedValue(undefined); + withBridge(clearGrants); + useAuthStore.setState({ isAuthenticated: true, activeAccountId: 'a1' }); + expect(clearGrants).not.toHaveBeenCalled(); + useAuthStore.setState({ activeAccountId: 'a2' }); + expect(clearGrants).toHaveBeenCalledTimes(1); + }); + + it('signs out all the same on a shell without the call, or one that fails', async () => { + withBridge(undefined); + await useAuthStore.getState().logout(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + + useAuthStore.setState({ isAuthenticated: true }); + withBridge(vi.fn().mockRejectedValue(new Error('gone'))); + await useAuthStore.getState().logout(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + }); + it('does not wait on a plugin that never answers', async () => { vi.useFakeTimers(); authHooks.onBeforeLogout.register('p', () => new Promise(() => {})); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 9fc0c7ca3..68c119c3c 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -18,6 +18,7 @@ import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation'; import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { clearKeystoreGrants } from '@/lib/desktop/keystore-grants'; import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in'; import { isEmbedded, notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager'; @@ -2461,6 +2462,7 @@ export const useAuthStore = create()( await useSettingsStore.getState().flushSync(); useSettingsStore.getState().disableSync(); + clearKeystoreGrants(); void authHooks.onAfterLogout.emit({ accountId: accountId ?? 'all', }); @@ -2635,6 +2637,7 @@ export const useAuthStore = create()( accountStore.removeAccount(account.id); } + clearKeystoreGrants(); void authHooks.onAfterLogout.emit({ accountId: 'all' }); @@ -3688,6 +3691,7 @@ useAuthStore.subscribe((state, prev) => { const wasReady = prev.isAuthenticated && !!prev.client && !!prev.activeAccountId; if (ready && !wasReady) void authHooks.onAuthReady.emit({ accountId: state.activeAccountId }); if (state.activeAccountId && prev.activeAccountId && state.activeAccountId !== prev.activeAccountId) { + clearKeystoreGrants(); void authHooks.onAccountSwitch.emit({ accountId: state.activeAccountId, previousAccountId: prev.activeAccountId }); } }); From 418bda4642de0334c0c30ce992cf680c4a4fa22c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:41:03 +0100 Subject: [PATCH 32/35] fix: stream kept files to the store, keep the old copy until the new one is in, and apply a tighter scope at once --- lib/store/__tests__/files.test.ts | 287 ++++++++++++++++-- lib/store/__tests__/helpers/fake-files.ts | 52 +++- lib/store/__tests__/helpers/fake-jmap.ts | 4 + lib/store/__tests__/transport.test.ts | 129 ++++++++ lib/store/area.ts | 2 + lib/store/engine/account-engine.ts | 17 +- lib/store/engine/transport.ts | 169 +++++++++-- lib/store/files/index.ts | 3 +- lib/store/files/keep.ts | 353 ++++++++++++++++------ lib/store/files/methods.ts | 8 +- lib/store/files/records.ts | 6 + lib/store/files/schema.ts | 9 + lib/store/files/sync.ts | 47 ++- 13 files changed, 926 insertions(+), 160 deletions(-) create mode 100644 lib/store/__tests__/transport.test.ts diff --git a/lib/store/__tests__/files.test.ts b/lib/store/__tests__/files.test.ts index acf81b483..acff96d1c 100644 --- a/lib/store/__tests__/files.test.ts +++ b/lib/store/__tests__/files.test.ts @@ -54,6 +54,9 @@ async function call(engine: AccountEngine, name: string, args: Record ({ jmap, id }); +const bytesOf = async (kept: unknown): Promise => new Uint8Array(await (kept as { blob: Blob }).blob.arrayBuffer()); +const keptIn = (db: StorageAdapter, blobId: string, scope: StoreScope = DEFAULT_SCOPE) => fileBlob({ db, scope }, JMAP_ACCOUNT, blobId); + function names(rows: Array<{ name?: unknown }>): unknown[] { return rows.map((row) => row.name).sort(); } @@ -110,7 +113,7 @@ describe('file metadata sync', () => { 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.find(([n, a]) => n === 'FileNode/query' && a.accountId === JMAP_ACCOUNT)?.[1].anchor).toBeTruthy(); expect(server.calls.filter(([n, a]) => n === 'FileNode/get' && a.ids === null && a.accountId === JMAP_ACCOUNT)).toHaveLength(0); }); @@ -159,6 +162,60 @@ describe('file answers', () => { expect(await engine.answer([['FileNode/get', { accountId: JMAP_ACCOUNT, ids: null }, '0']])).toBeNull(); }); + it('answers serverUnavailable until the first full pass is done', async () => { + const server = new FakeFiles({ getAll: 'refuse' }); + for (let i = 0; i < 6000; i += 1) server.addFile(`f${i}`); + const rig = rigFor(server); + let requests = 0; + server.beforeRequest = () => { + requests += 1; + if (requests === 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); + expect(await rig.engine.answer([['FileNode/get', { accountId: JMAP_ACCOUNT, ids: null }, '0']])).toBeNull(); + expect(await rig.engine.answer([['FileNode/query', { accountId: JMAP_ACCOUNT, filter: {} }, '0']])).toBeNull(); + server.offline = false; + server.beforeRequest = null; + await rig.engine.requestSync(); + expect((await call(rig.engine, 'FileNode/query', { filter: {}, calculateTotal: true })).total).toBe(6000); + }); + + it('goes on answering from the old copy while a later resync runs', async () => { + const server = new FakeFiles({ getAll: 'refuse' }); + for (let i = 0; i < 6000; i += 1) server.addFile(`f${i}`); + const { db, engine } = await synced(server); + db.run("UPDATE sync_state SET state = 'bad state!' WHERE type = 'FileNode'"); + let requests = 0; + server.beforeRequest = () => { + requests += 1; + if (requests === 3) server.offline = true; + }; + await engine.requestSync(); + expect((await call(engine, 'FileNode/query', { filter: {}, calculateTotal: true })).total).toBe(6000); + }); + + it('does not skip a node when an earlier one is deleted between pages, and starts over if the anchor goes', async () => { + for (const victim of [0, 4999]) { + const server = new FakeFiles({ getAll: 'refuse' }); + const files = Array.from({ length: 6000 }, (_, i) => server.addFile(`f${i}`)); + const rig = rigFor(server); + let queries = 0; + const original = server.request.bind(server); + server.request = async (calls) => { + if (calls.some(([name, args]) => name === 'FileNode/query' && args.accountId === JMAP_ACCOUNT)) { + queries += 1; + if (queries === 2) server.removeNode(files[victim].id); + } + return original(calls); + }; + await rig.engine.requestSync(); + const kept = new Set(rig.db.all<{ id: string }>('SELECT id FROM file_node').map((r) => r.id)); + expect(kept.size).toBe(5999); + expect(kept.has(files[victim].id)).toBe(false); + } + }); + it('gives the last server state and only asked-for properties', async () => { const server = new FakeFiles(); const file = server.addFile('a.txt'); @@ -234,8 +291,8 @@ describe('pinned files', () => { 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(Buffer.compare(await bytesOf(read), content)).toBe(0); + expect(Buffer.compare(await bytesOf(keptIn(db, file.blobId as string)), 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(); @@ -260,7 +317,7 @@ describe('pinned files', () => { 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(); + expect(keptIn(db, file.blobId as string)).toBeNull(); }); it('refuses to pin a folder or an unknown id', async () => { @@ -341,15 +398,208 @@ describe('pinned files', () => { expect((await engine.areaCall('files.kept', undefined) as any).files[0].error).toBe('tooLarge'); }); + it('stops a download at the limit when the size is missing or wrong', async () => { + const server = new FakeFiles(); + server.partBytes = 10; + const noSize = server.addFile('noSize', null, new Uint8Array(1000), { size: null }); + const wrong = server.addFile('wrong', null, new Uint8Array(1000), { size: 1 }); + const { db, engine } = await synced(server, { maxAttachmentBytes: 50 }); + await engine.areaCall('files.noteOpened', ref(noSize.id)); + await engine.areaCall('files.noteOpened', ref(wrong.id)); + await engine.requestSync(); + expect(server.delivered.get(noSize.blobId as string)).toBeLessThanOrEqual(60); + expect(server.delivered.get(wrong.blobId as string)).toBeLessThanOrEqual(60); + expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(0); + expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob')?.n)).toBe(0); + // Noted, so the next pass doesn't start it again. + const count = server.downloads.length; + await engine.requestSync(); + expect(server.downloads).toHaveLength(count); + }); + + it('stops a pinned download at the total limit too', async () => { + const server = new FakeFiles(); + server.partBytes = 10; + const big = server.addFile('big', null, new Uint8Array(1000), { size: null }); + const { engine } = await synced(server, { maxTotalBytes: 100 }); + await engine.areaCall('files.pin', ref(big.id)); + await engine.requestSync(); + expect(server.delivered.get(big.blobId as string)).toBeLessThanOrEqual(110); + expect((await engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ kept: false, error: 'overLimit' }); + }); + + it('writes a download in pieces and gives nothing out until it is complete', async () => { + const server = new FakeFiles(); + const file = server.addFile('big', null, new Uint8Array(3 * 1024 * 1024).fill(7)); + const { db, engine } = await synced(server); + await engine.areaCall('files.pin', ref(file.id)); + const seen: number[] = []; + let readDuring: unknown = 'not read'; + server.onPart = () => { + seen.push(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)); + readDuring = keptIn(db, file.blobId as string); + }; + await engine.requestSync(); + expect(Math.max(...seen)).toBeGreaterThanOrEqual(2); + expect(readDuring).toBeNull(); + expect((await bytesOf(keptIn(db, file.blobId as string))).length).toBe(3 * 1024 * 1024); + }); + + it('leaves no pieces behind after a broken download, and gives none out', async () => { + const server = new FakeFiles(); + const file = server.addFile('big', null, new Uint8Array(3 * 1024 * 1024).fill(7)); + // Twice, since the pin itself starts a pass and the sync below asks for another. + server.failMidBody.set(file.blobId as string, { after: 2 * 1024 * 1024, times: 2 }); + const { db, engine, clock } = await synced(server); + await engine.areaCall('files.pin', ref(file.id)); + await engine.requestSync(); + expect(server.failMidBody.get(file.blobId as string)?.times).toBe(0); + expect(await engine.areaCall('files.read', ref(file.id))).toBeNull(); + expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(0); + clock.at += 60 * 60 * 1000; + await engine.requestSync(); + expect((await bytesOf(await engine.areaCall('files.read', ref(file.id)))).length).toBe(3 * 1024 * 1024); + }); + + it('clears the pieces a closed app left behind', async () => { + const server = new FakeFiles(); + const file = server.addFile('a'); + const { db, engine } = await synced(server); + db.run('INSERT INTO file_blob (jmap, blob_id, type, size, kept_at, complete) VALUES (?, ?, ?, 0, 0, 0)', [JMAP_ACCOUNT, 'unfinished', 'x']); + db.run('INSERT INTO file_blob_chunk (jmap, blob_id, seq, data) VALUES (?, ?, 0, ?)', [JMAP_ACCOUNT, 'unfinished', new Uint8Array(5)]); + expect(keptIn(db, 'unfinished')).toBeNull(); + await engine.areaCall('files.pin', ref(file.id)); + await engine.requestSync(); + expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM file_blob_chunk WHERE blob_id = 'unfinished'")?.n)).toBe(0); + expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM file_blob WHERE blob_id = 'unfinished'")?.n)).toBe(0); + }); + + it('marks a file that keeps breaking and gets on with the others', async () => { + const server = new FakeFiles(); + const bad = server.addFile('bad', null, new Uint8Array(100)); + const good = server.addFile('good', null, 10); + server.partBytes = 10; + server.failMidBody.set(bad.blobId as string, { after: 50, times: 3 }); + const rig = await synced(server); + await rig.engine.areaCall('files.pin', ref(bad.id)); + await rig.engine.areaCall('files.pin', ref(good.id)); + await rig.engine.requestSync(); + expect(await rig.engine.areaCall('files.read', ref(good.id))).not.toBeNull(); + const badRow = async () => (await rig.engine.areaCall('files.kept', undefined) as any).files.find((f: { name: string }) => f.name === 'bad'); + expect(await badRow()).toMatchObject({ kept: false, error: 'download' }); + // Failed files go last, and the second failure starts a wait. + await rig.engine.requestSync(); + const tries = server.downloads.filter((d) => d.blobId === bad.blobId).length; + await rig.engine.requestSync(); + expect(server.downloads.filter((d) => d.blobId === bad.blobId)).toHaveLength(tries); + rig.clock.at += 60 * 60 * 1000; + await rig.engine.requestSync(); + rig.clock.at += 2 * 60 * 60 * 1000; + await rig.engine.requestSync(); + expect(await rig.engine.areaCall('files.read', ref(bad.id))).not.toBeNull(); + expect(await badRow()).toMatchObject({ kept: true, error: null }); + }); + + it('keeps the old copy of a pinned file until the new one is stored', 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])); + server.failMidBody.set(fresh, { after: 0, times: 1 }); + await engine.requestSync(); + // The new one didn't arrive: what's asked for by its new id is the last copy, marked as old. + expect([...await bytesOf(keptIn(db, old))]).toEqual([1, 2, 3]); + const stale = await engine.areaCall('files.read', ref(file.id)) as any; + expect([...await bytesOf(stale)]).toEqual([1, 2, 3]); + expect(stale.stale).toBe(true); + expect(keptIn(db, fresh)).toMatchObject({ stale: true }); + await engine.requestSync(); + expect(keptIn(db, old)).toBeNull(); + const current = await engine.areaCall('files.read', ref(file.id)) as any; + expect([...await bytesOf(current)]).toEqual([9, 9]); + expect(current.stale).toBe(false); + }); + + it('drops pins newest first when a policy lowers the limit, as soon as it changes', async () => { + const server = new FakeFiles(); + const a = server.addFile('a', null, 60); + const b = server.addFile('b', null, 60); + const c = server.addFile('c', null, 30); + const rig = await synced(server); + for (const file of [a, b, c]) { + rig.clock.at += 1000; + await rig.engine.areaCall('files.pin', ref(file.id)); + await rig.engine.requestSync(); + } + rig.engine.setScope({ ...DEFAULT_SCOPE, maxTotalBytes: 100 }); + const status = await rig.engine.areaCall('files.kept', undefined) as any; + const row = (name: string) => status.files.find((f: { name: string }) => f.name === name); + expect(row('a')).toMatchObject({ kept: true, pinned: true }); + expect(row('b')).toMatchObject({ kept: false, pinned: true, error: 'overLimit' }); + expect(row('c')).toMatchObject({ kept: true, pinned: true }); + expect(status.bytes).toBe(90); + const count = server.downloads.length; + await rig.engine.requestSync(); + expect(server.downloads).toHaveLength(count); + + rig.engine.setScope(DEFAULT_SCOPE); + await rig.engine.requestSync(); + expect((await rig.engine.areaCall('files.kept', undefined) as any).bytes).toBe(150); + }); + + it('applies a tighter policy at start too, before any sync', 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(); + await rig.engine.close(); + server.offline = true; + new AccountEngine({ account: 'a@fake.test', db: rig.db, session: FILES_SESSION, transport: server, areas: [filesArea], scope: { ...DEFAULT_SCOPE, attachments: false }, sleep: async () => {} }); + expect(Number(rig.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(0); + }); + + it('refuses kept contents with attachments off, and drops them when the scope changes', 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(); + const off = { ...DEFAULT_SCOPE, attachments: false }; + expect(keptIn(rig.db, file.blobId as string, off)).toBeNull(); + expect(keptIn(rig.db, file.blobId as string)).not.toBeNull(); + rig.engine.setScope(off); + 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({ kept: false, error: 'notAllowed' }); + }); + + it('lets a close end a download at once', async () => { + const server = new FakeFiles(); + const file = server.addFile('a'); + const { engine } = await synced(server); + await engine.areaCall('files.pin', ref(file.id)); + server.hangDownloads = true; + const pass = engine.requestSync(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(server.downloading).toBe(1); + const closed = await Promise.race([engine.close().then(() => 'closed'), new Promise((resolve) => setTimeout(() => resolve('still waiting'), 200))]); + expect(closed).toBe('closed'); + await pass; + }); + 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) => { + const original = server.downloadStream.bind(server); + server.downloadStream = async (jmap, blobId, type, name, sink) => { await engine.areaCall('files.unpin', ref(file.id)); - return original(jmap, blobId); + return original(jmap, blobId, type, name, sink); }; await engine.requestSync(); expect(server.downloads).toHaveLength(1); @@ -382,9 +632,9 @@ describe('pinned files', () => { 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]); + expect(keptIn(db, old)).toBeNull(); + expect([...await bytesOf(keptIn(db, fresh))]).toEqual([9, 9]); + expect([...await bytesOf(await engine.areaCall('files.read', ref(file.id)))]).toEqual([9, 9]); }); it('drops the content when the file is removed on the server, unless another node shares it', async () => { @@ -398,10 +648,10 @@ describe('pinned files', () => { expect(server.downloads).toHaveLength(1); server.removeNode(a.id); await engine.requestSync(); - expect(fileBlob({ db }, JMAP_ACCOUNT, 'shared-blob')).not.toBeNull(); + expect(keptIn(db, 'shared-blob')).not.toBeNull(); server.removeNode(b.id); await engine.requestSync(); - expect(fileBlob({ db }, JMAP_ACCOUNT, 'shared-blob')).toBeNull(); + expect(keptIn(db, 'shared-blob')).toBeNull(); expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(0); }); @@ -426,8 +676,8 @@ describe('pinned files', () => { 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 () => { + const original = server.downloadStream.bind(server); + server.downloadStream = async () => { throw new TransportError('network', 'Failed to fetch'); }; const syncedBefore = rig.events.filter((e) => e.type === 'synced').length; @@ -436,7 +686,7 @@ describe('pinned files', () => { 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; + server.downloadStream = original; await rig.engine.requestSync(); expect(await rig.engine.areaCall('files.read', ref(file.id))).not.toBeNull(); }); @@ -506,11 +756,11 @@ describe('recently opened files', () => { 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_blob (jmap, blob_id, type, size, kept_at, complete) VALUES (?, ?, ?, ?, ?, 1)', [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(keptIn(rig.db, '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); }); @@ -526,7 +776,8 @@ describe('recently opened files', () => { 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]); + // It can see that from the sizes, so it doesn't even download it. + expect(server.downloads.map((d) => d.blobId)).toEqual([b.blobId]); const count = server.downloads.length; await rig.engine.requestSync(); expect(server.downloads).toHaveLength(count); diff --git a/lib/store/__tests__/helpers/fake-files.ts b/lib/store/__tests__/helpers/fake-files.ts index 0ff22f4ed..19786cc8e 100644 --- a/lib/store/__tests__/helpers/fake-files.ts +++ b/lib/store/__tests__/helpers/fake-files.ts @@ -59,6 +59,17 @@ export class FakeFiles extends FakeJmap { readonly downloads: Array<{ jmap: string; blobId: string }> = []; downloading = 0; mostAtOnce = 0; + /** How a body arrives. */ + partBytes = 256 * 1024; + /** Blob ids whose body breaks after this many bytes, with how many more times it should. */ + readonly failMidBody = new Map(); + /** Bytes each blob's body actually delivered, last time. */ + readonly delivered = new Map(); + /** Called after each part of a body, to look at the store meanwhile. */ + onPart: ((blobId: string) => void) | null = null; + /** Set to make a download hang until the transport is aborted. */ + hangDownloads = false; + private readonly hanging = new Set<() => void>(); constructor(options: FakeFilesOptions = {}) { super(); @@ -162,7 +173,12 @@ export class FakeFiles extends FakeJmap { 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); + let position = Number(args.position ?? 0); + if (args.anchor !== undefined) { + const at = list.findIndex((n) => n.id === args.anchor); + if (at < 0) throw Object.assign(new Error('anchorNotFound'), { jmapError: 'anchorNotFound' }); + position = at + Number(args.anchorOffset ?? 0); + } const limit = Math.min(Number(args.limit ?? 5000) || 5000, 5000); return { accountId: args.accountId, @@ -178,15 +194,37 @@ export class FakeFiles extends FakeJmap { } } - override async download(jmap: string, blobId: string): Promise { + abort(): void { + for (const stop of this.hanging) stop(); + } + + override async downloadStream(jmap: string, blobId: string, _type: string, _name: string, sink: (part: Uint8Array) => void): Promise { 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; + try { + if (this.hangDownloads) { + await new Promise((resolve) => this.hanging.add(resolve)); + throw new TransportError('network', 'Aborted'); + } + const data = this.blobs.get(blobId); + if (!data) throw new TransportError('http', 'Download failed: 404', 404); + const failure = this.failMidBody.get(blobId); + this.delivered.set(blobId, 0); + for (let at = 0; at < data.length; at += this.partBytes) { + await Promise.resolve(); + if (failure && failure.times > 0 && at >= failure.after) { + failure.times -= 1; + throw new TransportError('network', 'Connection reset', undefined, undefined, undefined, true); + } + const part = data.subarray(at, at + this.partBytes); + this.delivered.set(blobId, at + part.length); + sink(part); + this.onPart?.(blobId); + } + } finally { + this.downloading -= 1; + } } } diff --git a/lib/store/__tests__/helpers/fake-jmap.ts b/lib/store/__tests__/helpers/fake-jmap.ts index 1fd274c04..7d84b66e2 100644 --- a/lib/store/__tests__/helpers/fake-jmap.ts +++ b/lib/store/__tests__/helpers/fake-jmap.ts @@ -279,6 +279,10 @@ export class FakeJmap implements JmapTransport { return new Uint8Array(); } + async downloadStream(jmap: string, blobId: string, type: string, name: string, sink: (part: Uint8Array) => void): Promise { + sink(await this.download(jmap, blobId, type, name)); + } + async upload(): Promise<{ blobId: string; type: string; size: number }> { return { blobId: 'uploaded', type: 'application/octet-stream', size: 0 }; } diff --git a/lib/store/__tests__/transport.test.ts b/lib/store/__tests__/transport.test.ts new file mode 100644 index 000000000..780837583 --- /dev/null +++ b/lib/store/__tests__/transport.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FetchTransport, TransportError, type StoreSession } from '../engine/transport'; + +const SESSION: StoreSession = { + apiUrl: 'https://fake.test/jmap/', + downloadUrl: 'https://fake.test/d/{accountId}/{blobId}/{name}?type={type}', + uploadUrl: 'https://fake.test/u/{accountId}', + primaryAccounts: {}, + accounts: {}, + capabilities: {}, +}; + +type Pull = (controller: ReadableStreamDefaultController) => void | Promise; + +/** A response whose body comes as the test says, and that fails when the request is aborted, as fetch does. */ +function bodyResponse(signal: AbortSignal | null | undefined, pull: Pull): Response { + const stream = new ReadableStream({ + start(controller) { + signal?.addEventListener('abort', () => controller.error(new DOMException('Aborted', 'AbortError'))); + }, + pull, + }); + return new Response(stream, { status: 200 }); +} + +function transportWith(respond: (init: RequestInit) => Response | Promise): FetchTransport { + return new FetchTransport({ + session: () => SESSION, + auth: () => 'Basic x', + refreshAuth: async () => 'Basic x', + fetch: (async (_url: string, init: RequestInit) => respond(init)) as unknown as typeof fetch, + }); +} + +afterEach(() => vi.useRealTimers()); + +describe('downloads', () => { + it('gives a body piece by piece and adds them up for a plain download', async () => { + const parts = [new Uint8Array([1, 2]), new Uint8Array([3])]; + const open = () => transportWith((init) => { + let i = 0; + return bodyResponse(init.signal, (c) => { + if (i < parts.length) c.enqueue(parts[i++]); + else c.close(); + }); + }); + const seen: number[] = []; + await open().downloadStream('a', 'b', 't', 'n', (part) => seen.push(part.length)); + expect(seen).toEqual([2, 1]); + expect([...await open().download('a', 'b', 't', 'n')]).toEqual([1, 2, 3]); + }); + + it('turns a body that breaks into a TransportError saying so', async () => { + const open = () => transportWith((init) => { + let i = 0; + return bodyResponse(init.signal, (c) => { + if (i++ === 0) c.enqueue(new Uint8Array(1)); + else c.error(new TypeError('network error')); + }); + }); + const broken = await open().download('a', 'b', 't', 'n').catch((e: unknown) => e); + expect(broken).toBeInstanceOf(TransportError); + expect(broken).toMatchObject({ kind: 'network', interrupted: true }); + }); + + it('lets a slow download run past the old total limit while bytes keep coming', async () => { + vi.useFakeTimers(); + let sent = 0; + const transport = transportWith((init) => bodyResponse(init.signal, async (c) => { + await new Promise((resolve) => setTimeout(resolve, 30_000)); + if (sent++ < 10) c.enqueue(new Uint8Array(1)); + else c.close(); + })); + let bytes = 0; + const done = transport.downloadStream('a', 'b', 't', 'n', (part) => { bytes += part.length; }); + await vi.advanceTimersByTimeAsync(11 * 30_000); + await done; + expect(bytes).toBe(10); + }); + + it('gives up when no bytes come for a while, and says it was this download', async () => { + vi.useFakeTimers(); + const transport = transportWith((init) => bodyResponse(init.signal, () => new Promise(() => {}))); + const done = transport.downloadStream('a', 'b', 't', 'n', () => {}).catch((e: unknown) => e); + await vi.advanceTimersByTimeAsync(5 * 60_000); + expect(await done).toMatchObject({ kind: 'network', interrupted: true }); + }); + + it('lets an error from the sink through and stops reading', async () => { + let cancelled = false; + const transport = transportWith(() => new Response(new ReadableStream({ + pull: (c) => c.enqueue(new Uint8Array(1)), + cancel: () => { cancelled = true; }, + }))); + const stop = new Error('over the limit'); + await expect(transport.downloadStream('a', 'b', 't', 'n', () => { throw stop; })).rejects.toBe(stop); + expect(cancelled).toBe(true); + }); + + it('keeps a plain network failure apart from a broken download', async () => { + const transport = transportWith(() => { throw new TypeError('Failed to fetch'); }); + const failed = await transport.downloadStream('a', 'b', 't', 'n', () => {}).catch((e: unknown) => e); + expect(failed).toMatchObject({ kind: 'network' }); + expect((failed as TransportError).interrupted).toBeFalsy(); + }); +}); + +describe('requests', () => { + it('turns a broken response body into a TransportError', async () => { + const transport = transportWith((init) => bodyResponse(init.signal, (c) => c.error(new TypeError('network error')))); + const failed = await transport.request([['Core/echo', {}, '0']], ['urn:ietf:params:jmap:core']).catch((e: unknown) => e); + expect(failed).toBeInstanceOf(TransportError); + expect(failed).toMatchObject({ kind: 'network' }); + }); +}); + +describe('abort', () => { + it('ends what is running at once, and anything after it', async () => { + const transport = transportWith((init) => bodyResponse(init.signal, () => new Promise(() => {}))); + const running = transport.downloadStream('a', 'b', 't', 'n', () => {}).catch((e: unknown) => e); + const request = transport.request([['Core/echo', {}, '0']], ['x']).catch((e: unknown) => e); + await new Promise((resolve) => setTimeout(resolve, 5)); + transport.abort(); + expect(await running).toBeInstanceOf(TransportError); + expect(await request).toBeInstanceOf(TransportError); + await expect(transport.download('a', 'b', 't', 'n')).rejects.toBeInstanceOf(TransportError); + }); +}); diff --git a/lib/store/area.ts b/lib/store/area.ts index 9bd11fd35..c9c9f0688 100644 --- a/lib/store/area.ts +++ b/lib/store/area.ts @@ -108,4 +108,6 @@ export interface StoreArea { methods: Readonly>; syncers: readonly Syncer[]; calls?: Readonly>; + /** At start and when the scope changes: a tighter policy applies at once, not at the next sync. */ + scopeChanged?(ctx: StoreContext): void; } diff --git a/lib/store/engine/account-engine.ts b/lib/store/engine/account-engine.ts index a3d56298e..a8510346d 100644 --- a/lib/store/engine/account-engine.ts +++ b/lib/store/engine/account-engine.ts @@ -34,6 +34,7 @@ export class AccountEngine { private scope: StoreScope; private readonly transport: JmapTransport; private readonly syncers: readonly Syncer[]; + private readonly areas: readonly StoreArea[]; private readonly methods: ReadonlyMap; private readonly calls = new Map(); private readonly emitter: (event: StoreEvent) => void; @@ -51,6 +52,7 @@ export class AccountEngine { this.session = options.session; this.scope = options.scope; this.transport = options.transport; + this.areas = options.areas; this.syncers = options.areas.flatMap((area) => area.syncers); this.methods = buildMethodTable(options.areas); for (const area of options.areas) { @@ -65,6 +67,12 @@ export class AccountEngine { this.invertedKeywordSort = options.invertedKeywordSort ?? false; migrate(this.db, [...CORE_MIGRATIONS, ...options.areas.flatMap((area) => area.migrations)]); metaSet(this.db, 'session', JSON.stringify(options.session)); + this.applyScope(); + } + + private applyScope(): void { + const ctx = this.storeContext(); + for (const area of this.areas) area.scopeChanged?.(ctx); } /** The session kept from the last time, for a start without a network. */ @@ -82,6 +90,7 @@ export class AccountEngine { setScope(scope: StoreScope): void { this.scope = scope; metaSet(this.db, 'scope', JSON.stringify(scope)); + this.applyScope(); } setInvertedKeywordSort(inverted: boolean): void { @@ -188,11 +197,15 @@ export class AccountEngine { } private syncContext(): SyncContext { + const scope = () => this.scope; return { account: this.account, db: this.db, session: this.session, - scope: this.scope, + // A pass can take long, and a tighter policy applies to the rest of it. + get scope() { + return scope(); + }, transport: this.transport, now: this.now, emit: this.emitter, @@ -240,6 +253,8 @@ export class AccountEngine { /** Stops syncing and waits for the current batch. The database stays open for the caller to close. */ async close(): Promise { this.closed = true; + // A large download could otherwise hold up removing the account. + this.transport.abort?.(); await this.running?.catch(() => {}); } } diff --git a/lib/store/engine/transport.ts b/lib/store/engine/transport.ts index cc55ff300..d1edaee6e 100644 --- a/lib/store/engine/transport.ts +++ b/lib/store/engine/transport.ts @@ -34,6 +34,8 @@ export class TransportError extends Error { /** The `type` of an RFC 7807 problem body, when there was one. */ readonly problem?: string, readonly retryAfterMs?: number, + /** The connection worked and this transfer broke or stalled: it says little about the next one. */ + readonly interrupted?: boolean, ) { super(message); this.name = 'TransportError'; @@ -43,7 +45,14 @@ export class TransportError extends Error { export interface JmapTransport { request(calls: MethodCall[], using: string[]): Promise; download(jmap: string, blobId: string, type: string, name: string): Promise; + /** + * The same, piece by piece, so a large file isn't held in memory. An error + * thrown by `sink` ends the download and comes out as it is. + */ + downloadStream(jmap: string, blobId: string, type: string, name: string, sink: (part: Uint8Array) => void): Promise; upload(jmap: string, data: Uint8Array, type: string): Promise<{ blobId: string; type: string; size: number }>; + /** Ends what's running, now and for good. The engine calls this when it closes. */ + abort?(): void; } export interface FetchTransportOptions { @@ -56,23 +65,72 @@ export interface FetchTransportOptions { } const REQUEST_TIMEOUT_MS = 120_000; +/** A download may take as long as it takes, as long as bytes keep coming. */ +const DOWNLOAD_IDLE_MS = 60_000; function fillTemplate(template: string, values: Record): string { return template.replace(/\{(\w+)\}/g, (whole, key: string) => (key in values ? encodeURIComponent(values[key]) : whole)); } +/** One transfer's way to be stopped: by its time limit, or by the transport closing. */ +interface Transfer { + signal: AbortSignal; + /** Bytes came: for a download, the time limit starts over. */ + alive(): void; + /** Whether the time limit ended it. */ + timedOut(): boolean; + done(): void; +} + +function messageOf(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback; +} + export class FetchTransport implements JmapTransport { + private readonly running = new Set(); + private aborted = false; + constructor(private readonly options: FetchTransportOptions) {} - private async fetchWithAuth(url: string, init: RequestInit): Promise { + abort(): void { + this.aborted = true; + for (const controller of this.running) controller.abort(); + } + + private transfer(ms: number, idle: boolean): Transfer { + const controller = new AbortController(); + this.running.add(controller); + let timedOut = false; + const arm = () => setTimeout(() => { + timedOut = true; + controller.abort(); + }, ms); + let timer = arm(); + if (this.aborted) controller.abort(); + return { + signal: controller.signal, + alive: () => { + if (!idle) return; + clearTimeout(timer); + timer = arm(); + }, + timedOut: () => timedOut, + done: () => { + clearTimeout(timer); + this.running.delete(controller); + }, + }; + } + + private async fetchWithAuth(url: string, init: RequestInit, transfer: Transfer): Promise { const doFetch = this.options.fetch ?? fetch; const send = async (header: string | null): Promise => { if (!header) throw new TransportError('auth', 'No credentials yet'); - const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + if (this.aborted) throw new TransportError('network', 'The store is closing'); try { - return await doFetch(url, { ...init, signal, headers: { ...init.headers, Authorization: header } }); + return await doFetch(url, { ...init, signal: transfer.signal, headers: { ...init.headers, Authorization: header } }); } catch (error) { - throw new TransportError('network', error instanceof Error ? error.message : 'Network failure'); + throw new TransportError('network', messageOf(error, 'Network failure'), undefined, undefined, undefined, transfer.timedOut()); } }; let response = await send(this.options.auth()); @@ -87,40 +145,97 @@ export class FetchTransport implements JmapTransport { return response; } + /** A JSON body. A connection dropping halfway is a network failure, not a bad answer. */ + private async readJson(response: Response): Promise { + try { + return await response.json() as T; + } catch (error) { + if (error instanceof SyntaxError) throw new TransportError('http', 'The server sent something that is not JSON', response.status); + throw new TransportError('network', messageOf(error, 'The response broke off'), undefined, undefined, undefined, true); + } + } + async request(calls: MethodCall[], using: string[]): Promise { - const response = await this.fetchWithAuth(this.options.session().apiUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ using, methodCalls: calls }), - }); - if (!response.ok) { - let problem: string | undefined; + const transfer = this.transfer(REQUEST_TIMEOUT_MS, false); + try { + const response = await this.fetchWithAuth(this.options.session().apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ using, methodCalls: calls }), + }, transfer); + if (!response.ok) { + let problem: string | undefined; + try { + problem = (await response.json() as { type?: string }).type; + } catch { + // Not a problem document. + } + throw new TransportError('http', `JMAP request failed: ${response.status}`, response.status, problem); + } + return await this.readJson(response); + } finally { + transfer.done(); + } + } + + async downloadStream(jmap: string, blobId: string, type: string, name: string, sink: (part: Uint8Array) => void): Promise { + const url = fillTemplate(this.options.session().downloadUrl, { accountId: jmap, blobId, type, name }); + const transfer = this.transfer(DOWNLOAD_IDLE_MS, true); + try { + const response = await this.fetchWithAuth(url, { method: 'GET' }, transfer); + if (!response.ok) throw new TransportError('http', `Download failed: ${response.status}`, response.status); + if (!response.body) return; + const reader = response.body.getReader(); try { - problem = (await response.json() as { type?: string }).type; - } catch { - // Not a problem document. + for (;;) { + let read: Awaited>; + try { + read = await reader.read(); + } catch (error) { + throw new TransportError('network', messageOf(error, 'The download broke off'), undefined, undefined, undefined, !this.aborted); + } + if (read.done) return; + transfer.alive(); + sink(read.value); + } + } finally { + void reader.cancel().catch(() => {}); } - throw new TransportError('http', `JMAP request failed: ${response.status}`, response.status, problem); + } finally { + transfer.done(); } - return await response.json() as JmapResponse; } async download(jmap: string, blobId: string, type: string, name: string): Promise { - const url = fillTemplate(this.options.session().downloadUrl, { accountId: jmap, blobId, type, name }); - const response = await this.fetchWithAuth(url, { method: 'GET' }); - if (!response.ok) throw new TransportError('http', `Download failed: ${response.status}`, response.status); - return new Uint8Array(await response.arrayBuffer()); + const parts: Uint8Array[] = []; + let size = 0; + await this.downloadStream(jmap, blobId, type, name, (part) => { + parts.push(part); + size += part.length; + }); + const data = new Uint8Array(size); + let at = 0; + for (const part of parts) { + data.set(part, at); + at += part.length; + } + return data; } async upload(jmap: string, data: Uint8Array, type: string): Promise<{ blobId: string; type: string; size: number }> { const url = fillTemplate(this.options.session().uploadUrl, { accountId: jmap }); - const response = await this.fetchWithAuth(url, { - method: 'POST', - headers: { 'Content-Type': type || 'application/octet-stream' }, - body: data as BodyInit, - }); - if (!response.ok) throw new TransportError('http', `Upload failed: ${response.status}`, response.status); - return await response.json() as { blobId: string; type: string; size: number }; + const transfer = this.transfer(REQUEST_TIMEOUT_MS, false); + try { + const response = await this.fetchWithAuth(url, { + method: 'POST', + headers: { 'Content-Type': type || 'application/octet-stream' }, + body: data as BodyInit, + }, transfer); + if (!response.ok) throw new TransportError('http', `Upload failed: ${response.status}`, response.status); + return await this.readJson<{ blobId: string; type: string; size: number }>(response); + } finally { + transfer.done(); + } } } diff --git a/lib/store/files/index.ts b/lib/store/files/index.ts index 0957ced89..97f60baef 100644 --- a/lib/store/files/index.ts +++ b/lib/store/files/index.ts @@ -1,5 +1,5 @@ import type { StoreArea } from '../area'; -import { fileBlob, keptStatus, noteOpened, pinFile, readFile, unpinFile, type NodeRef } from './keep'; +import { applyScope, fileBlob, keptStatus, noteOpened, pinFile, readFile, unpinFile, type NodeRef } from './keep'; import { FILE_METHODS } from './methods'; import { FILE_MIGRATIONS } from './schema'; import { filesSyncer } from './sync'; @@ -11,6 +11,7 @@ export const filesArea: StoreArea = { migrations: FILE_MIGRATIONS, methods: FILE_METHODS, syncers: [filesSyncer], + scopeChanged: applyScope, calls: { /** Keep this file's content on the device until it's unpinned. */ 'files.pin': (ctx, params: NodeRef) => pinFile(ctx, params), diff --git a/lib/store/files/keep.ts b/lib/store/files/keep.ts index a11b93455..6ef464f20 100644 --- a/lib/store/files/keep.ts +++ b/lib/store/files/keep.ts @@ -5,11 +5,18 @@ * 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 size limit for one file doesn't apply. The - * account's total limit (scope.maxTotalBytes, which a policy can lower) does. + * account's total limit (scope.maxTotalBytes, which a policy can lower) does: + * past it, the newest pins lose their content and say `overLimit`. * - 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. + * - With scope.attachments off, nothing is kept or given out. + * + * A file comes in as a stream, written in 1 MB chunks that each commit on their + * own, and is held nowhere whole. `complete` says the last chunk is in, so a + * broken download or a closed app leaves nothing readable. Reading goes chunk by + * chunk into a Blob, which the browser can keep out of the worker's memory. + * A new version's content replaces the old one only when it's all there. */ import { TransportError } from '../engine/transport'; @@ -19,8 +26,17 @@ import { nodeMediaType, type WireNode } from './records'; export const FILE_CACHE_CAP_BYTES = 500 * 1024 * 1024; const CHUNK_BYTES = 1024 * 1024; +/** After the second failure in a row a file waits this long, doubling, before the next try. */ +const RETRY_WAIT_MS = 15 * 60 * 1000; +const MOST_RETRY_WAIT_MS = 24 * 60 * 60 * 1000; -export interface KeptFile { data: Uint8Array; type: string; size: number } +export interface KeptFile { + blob: Blob; + type: string; + size: number; + /** This is the version before the one asked for, whose content hasn't arrived. */ + stale: boolean; +} export interface NodeRef { jmap: string; id: string } @@ -30,6 +46,7 @@ interface NodeRow { name: string; kind: string; blob_id: string | null; + stale_blob_id: string | null; size: number | null; json: string; pinned: number; @@ -43,6 +60,7 @@ interface BlobRow { size: number; kept_at: number; pinned: number; + pinned_at: number | null; opened_at: number | null; } @@ -50,41 +68,52 @@ function capBytes(ctx: Pick): 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, 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; +function openedLimit(ctx: Pick): number { + return Math.min(ctx.scope.maxAttachmentBytes, capBytes(ctx)); +} + +function assemble(db: StorageAdapter, jmap: string, blobId: string, type: string): Blob { + let blob = new Blob([], { type }); + for (let seq = 0; ; seq += 1) { + const chunk = db.get<{ data: Uint8Array }>('SELECT data FROM file_blob_chunk WHERE jmap = ? AND blob_id = ? AND seq = ?', [jmap, blobId, seq]); + if (!chunk) return blob; + // Each step only refers to the last Blob, so one chunk at a time is in memory here. + blob = new Blob([blob, chunk.data as Uint8Array], { type }); } - return { data, type: row.type, size: Number(row.size) }; +} + +/** + * A kept file's content, for the client's blob lookup. If this version hasn't + * arrived and the one before is still kept, that one, marked `stale`. + */ +export function fileBlob(ctx: Pick, jmap: string, blobId: string): KeptFile | null { + if (!ctx.scope.attachments) return null; + const read = (id: string, stale: boolean): KeptFile | null => { + const row = ctx.db.get<{ type: string; size: number }>('SELECT type, size FROM file_blob WHERE jmap = ? AND blob_id = ? AND complete = 1', [jmap, id]); + return row ? { blob: assemble(ctx.db, jmap, id, row.type), type: row.type, size: Number(row.size), stale } : null; + }; + const current = read(blobId, false); + if (current) return current; + const older = ctx.db.get<{ stale_blob_id: string }>('SELECT stale_blob_id FROM file_node WHERE jmap = ? AND blob_id = ? AND stale_blob_id IS NOT NULL LIMIT 1', [jmap, blobId]); + return older ? read(older.stale_blob_id, true) : null; } 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])); + return Boolean(db.get('SELECT 1 AS x FROM file_blob WHERE jmap = ? AND blob_id = ? AND complete = 1', [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]); + db.run('UPDATE file_node SET stale_blob_id = NULL WHERE jmap = ? AND stale_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. */ +/** All complete contents in the store, with what the nodes pointing at each say about it. */ function keptBlobs(db: StorageAdapter): BlobRow[] { return db.all( - `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 + `SELECT b.jmap, b.blob_id, b.size, b.kept_at, COALESCE(MAX(n.pinned), 0) AS pinned, MAX(n.pinned_at) AS pinned_at, 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 OR n.stale_blob_id = b.blob_id) + WHERE b.complete = 1 GROUP BY b.jmap, b.blob_id`, ); } @@ -94,16 +123,16 @@ function ageOf(blob: BlobRow): number { } function pushOut(db: StorageAdapter, blob: BlobRow): void { + db.run('UPDATE file_node SET opened_at = NULL WHERE jmap = ? AND (blob_id = ? OR stale_blob_id = ?)', [blob.jmap, blob.blob_id, blob.blob_id]); 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. + * that isn't enough. A pinned one always gets in. With `dryRun` it only says. */ -function makeRoom(ctx: StoreContext, size: number, incoming: { pinned: boolean; openedAt: number }): boolean { +function makeRoom(ctx: StoreContext, size: number, incoming: { pinned: boolean; openedAt: number }, dryRun = false): boolean { const cap = capBytes(ctx); const blobs = keptBlobs(ctx.db); let used = blobs.reduce((sum, blob) => sum + Number(blob.size), 0); @@ -117,113 +146,252 @@ function makeRoom(ctx: StoreContext, size: number, incoming: { pinned: boolean; chosen.push(blob); } if (used + size > cap && !incoming.pinned) return false; - for (const blob of chosen) pushOut(ctx.db, blob); + if (!dryRun) for (const blob of chosen) pushOut(ctx.db, blob); return true; } -function pinnedBytes(db: StorageAdapter): number { - return keptBlobs(db).filter((blob) => blob.pinned).reduce((sum, blob) => sum + Number(blob.size), 0); +/** What pinned contents take. Leaves out the old version a new one will replace. */ +function pinnedBytes(db: StorageAdapter, except: string | null = null): number { + return keptBlobs(db).filter((blob) => blob.pinned && blob.blob_id !== except).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]); + db.run('UPDATE file_node SET keep_error = ? WHERE jmap = ? AND (blob_id = ? OR stale_blob_id = ?)', [error, jmap, blobId, blobId]); } -/** Contents no node points at any more: the file was removed or has a new version. */ +/** Makes the store fit the scope as it is now. Runs at start, when the scope changes and before fetching. */ +export function applyScope(ctx: StoreContext): void { + const { db } = ctx; + db.transaction(() => { + if (!ctx.scope.attachments) { + for (const row of db.all<{ jmap: string; blob_id: string }>('SELECT jmap, blob_id FROM file_blob')) dropBlob(db, row.jmap, row.blob_id); + db.run("UPDATE file_node SET keep_error = 'notAllowed' WHERE pinned = 1 OR opened_at IS NOT NULL"); + return; + } + db.run("UPDATE file_node SET keep_error = NULL WHERE keep_error = 'notAllowed'"); + // Oldest pins first, so the newest are the ones to lose out. One that still + // fits stays: dropping it would only have the next pass fetch it back. + const pins = keptBlobs(db).filter((blob) => blob.pinned).sort((a, b) => Number(a.pinned_at ?? 0) - Number(b.pinned_at ?? 0)); + let used = 0; + for (const blob of pins) { + if (used + Number(blob.size) <= ctx.scope.maxTotalBytes) { + used += Number(blob.size); + } else { + setError(db, blob.jmap, blob.blob_id, 'overLimit'); + dropBlob(db, blob.jmap, blob.blob_id); + } + } + makeRoom(ctx, 0, { pinned: true, openedAt: 0 }); + }); +} + +/** Contents no node points at any more: the file was removed or has a new version in place. */ 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]); + const used = db.get('SELECT 1 AS x FROM file_node WHERE jmap = ? AND (blob_id = ? OR stale_blob_id = ?)', [blob.jmap, blob.blob_id, 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 } +interface Wanted { + blob_id: string; + id: string; + name: string; + size: number | null; + json: string; + pinned: number; + opened_at: number | null; + stale_blob_id: string | null; + keep_failures: number; + keep_failed_at: number | null; +} function wantedRow(db: StorageAdapter, jmap: string, blobId: string): Wanted | undefined { return db.get( - `SELECT blob_id, id, name, size, json, pinned, opened_at FROM file_node + `SELECT blob_id, id, name, size, json, pinned, opened_at, stale_blob_id, keep_failures, keep_failed_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. */ +/** A file that failed once goes again next pass. After that it waits, so it can't eat every pass. */ +function stillWaiting(ctx: StoreContext, want: Wanted): boolean { + const failures = Number(want.keep_failures); + if (failures < 2 || want.keep_failed_at === null) return false; + const wait = Math.min(RETRY_WAIT_MS * 2 ** (failures - 2), MOST_RETRY_WAIT_MS); + return ctx.now() < Number(want.keep_failed_at) + wait; +} + +/** Thrown from the sink to end a download that's no longer wanted. */ +class DownloadStopped extends Error {} + +/** Thrown from the sink when a download passes its limit, whatever the metadata said. */ +class DownloadTooLarge extends Error { + constructor(readonly seen: number) { + super('Over the limit'); + } +} + +/** Takes a body piece by piece and writes it in chunks, each committed on its own. */ +function chunkWriter(ctx: SyncContext, jmap: string, blobId: string, limit: number) { + const buffer = new Uint8Array(CHUNK_BYTES); + let filled = 0; + let seq = 0; + let total = 0; + const flush = () => { + if (filled === 0) return; + if (ctx.stopped() || !ctx.scope.attachments || !wantedRow(ctx.db, jmap, blobId)) throw new DownloadStopped(); + ctx.db.run('INSERT INTO file_blob_chunk (jmap, blob_id, seq, data) VALUES (?, ?, ?, ?)', [jmap, blobId, seq, buffer.subarray(0, filled)]); + seq += 1; + filled = 0; + }; + return { + push(part: Uint8Array): void { + total += part.length; + if (total > limit) throw new DownloadTooLarge(total); + for (let at = 0; at < part.length;) { + const take = Math.min(part.length - at, CHUNK_BYTES - filled); + buffer.set(part.subarray(at, at + take), filled); + filled += take; + at += take; + if (filled === CHUNK_BYTES) flush(); + } + }, + finish(): number { + flush(); + return total; + }, + }; +} + +function markFailed(ctx: StoreContext, jmap: string, blobId: string): void { + ctx.db.run( + "UPDATE file_node SET keep_error = 'download', keep_failures = keep_failures + 1, keep_failed_at = ? WHERE jmap = ? AND blob_id = ?", + [ctx.now(), jmap, blobId], + ); +} + +/** The last chunk is in: the file becomes readable if it still fits and is still wanted. */ +function completeBlob(ctx: SyncContext, jmap: string, blobId: string, size: number): void { + const { db } = ctx; + db.transaction(() => { + const now = wantedRow(db, jmap, blobId); + if (!now || !ctx.scope.attachments) { + dropBlob(db, jmap, blobId); + return; + } + if (now.pinned && pinnedBytes(db, now.stale_blob_id) + size > ctx.scope.maxTotalBytes) { + dropBlob(db, jmap, blobId); + setError(db, jmap, blobId, 'overLimit'); + return; + } + if (!makeRoom(ctx, size, { pinned: Boolean(now.pinned), openedAt: Number(now.opened_at ?? 0) })) { + dropBlob(db, jmap, blobId); + db.run('UPDATE file_node SET opened_at = NULL WHERE jmap = ? AND blob_id = ? AND pinned = 0', [jmap, blobId]); + return; + } + db.run('UPDATE file_blob SET complete = 1, size = ?, kept_at = ? WHERE jmap = ? AND blob_id = ?', [size, ctx.now(), jmap, blobId]); + // The new version is all here, so the old one can go. + const olds = db.all<{ stale_blob_id: string }>('SELECT DISTINCT stale_blob_id FROM file_node WHERE jmap = ? AND blob_id = ? AND stale_blob_id IS NOT NULL', [jmap, blobId]); + db.run('UPDATE file_node SET stale_blob_id = NULL, keep_error = NULL, keep_failures = 0, keep_failed_at = NULL WHERE jmap = ? AND blob_id = ?', [jmap, blobId]); + for (const { stale_blob_id: old } of olds) { + if (!db.get('SELECT 1 AS x FROM file_node WHERE jmap = ? AND (blob_id = ? OR stale_blob_id = ?)', [jmap, old, old])) dropBlob(db, jmap, old); + } + }); +} + +async function fetchOne(ctx: SyncContext, jmap: string, want: Wanted): Promise { + const { db } = ctx; + const blobId = want.blob_id; + const type = nodeMediaType(JSON.parse(want.json) as WireNode); + const limit = want.pinned ? ctx.scope.maxTotalBytes - pinnedBytes(db, want.stale_blob_id) : openedLimit(ctx); + db.transaction(() => { + 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]); + db.run('INSERT INTO file_blob (jmap, blob_id, type, size, kept_at, complete) VALUES (?, ?, ?, 0, ?, 0)', [jmap, blobId, type, ctx.now()]); + }); + const writer = chunkWriter(ctx, jmap, blobId, limit); + const forget = () => db.transaction(() => { + 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]); + }); + try { + await ctx.transport.downloadStream(jmap, blobId, type, want.name, (part) => writer.push(part)); + completeBlob(ctx, jmap, blobId, writer.finish()); + return true; + } catch (error) { + forget(); + if (error instanceof DownloadStopped) return false; + if (error instanceof DownloadTooLarge) { + // With the size it now knows, the next pass sees this without starting the download. + db.run('UPDATE file_node SET size = ? WHERE jmap = ? AND blob_id = ?', [error.seen, jmap, blobId]); + setError(db, jmap, blobId, want.pinned ? 'overLimit' : 'tooLarge'); + return false; + } + // This file broke, or the server won't give it: the next one may be fine. No + // network at all or no credentials reach the engine, and the next pass tries again. + if (error instanceof TransportError && (error.kind === 'http' || error.interrupted)) { + markFailed(ctx, jmap, blobId); + return false; + } + throw error; + } +} + +/** Fetches what should be kept and isn't yet, one file at a time, pinned first and failed ones last. */ export async function fetchKeptFiles(ctx: SyncContext, jmap: string): Promise { 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; - } + // What a closed app or a stopped pass left unfinished. 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 }); + for (const row of db.all<{ blob_id: string }>('SELECT blob_id FROM file_blob WHERE jmap = ? AND complete = 0', [jmap])) { + db.run('DELETE FROM file_blob_chunk WHERE jmap = ? AND blob_id = ?', [jmap, row.blob_id]); + db.run('DELETE FROM file_blob WHERE jmap = ? AND blob_id = ?', [jmap, row.blob_id]); + } }); + applyScope(ctx); + if (!ctx.scope.attachments) return; 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`, + AND NOT EXISTS (SELECT 1 FROM file_blob b WHERE b.jmap = n.jmap AND b.blob_id = n.blob_id AND b.complete = 1) + GROUP BY n.blob_id ORDER BY MAX(n.keep_failures) > 0 ASC, 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; + if (ctx.stopped() || !ctx.scope.attachments) return; // The page may have changed its mind during the last download. const want = wantedRow(db, jmap, blobId); - if (!want) continue; + if (!want || stillWaiting(ctx, want)) continue; const known = Number(want.size ?? 0); - if (!want.pinned && known > Math.min(ctx.scope.maxAttachmentBytes, capBytes(ctx))) { + if (!want.pinned && known > openedLimit(ctx)) { setError(db, jmap, blobId, 'tooLarge'); continue; } - if (want.pinned && pinnedBytes(db) + known > ctx.scope.maxTotalBytes) { + if (want.pinned && pinnedBytes(db, want.stale_blob_id) + 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; + if (!makeRoom(ctx, known, { pinned: Boolean(want.pinned), openedAt: Number(want.opened_at ?? 0) }, true)) { + // Older than all that's kept, and no room: not worth the download. + db.run('UPDATE file_node SET opened_at = NULL WHERE jmap = ? AND blob_id = ? AND pinned = 0', [jmap, blobId]); + continue; + } + if (await fetchOne(ctx, jmap, want)) { + done += 1; + ctx.emit({ type: 'progress', account: ctx.account, area: 'files', phase: 'contents', done, total: todo.length }); } - db.transaction(() => { - const now = wantedRow(db, jmap, blobId); - if (!now) return; - if (now.pinned ? 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 ? 'overLimit' : 'tooLarge'); - 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'] }); } // ---- Calls from the page ---- +const ROW_COLUMNS = 'jmap, id, name, kind, blob_id, stale_blob_id, size, json, pinned, opened_at, keep_error'; + function fileRow(db: StorageAdapter, { jmap, id }: NodeRef): NodeRow { - const row = db.get('SELECT jmap, id, name, kind, blob_id, size, json, pinned, opened_at, keep_error FROM file_node WHERE jmap = ? AND id = ?', [jmap, id]); + const row = db.get(`SELECT ${ROW_COLUMNS} 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; @@ -231,7 +399,7 @@ function fileRow(db: StorageAdapter, { jmap, id }: NodeRef): NodeRow { 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]); + ctx.db.run('UPDATE file_node SET pinned = 1, pinned_at = ?, keep_error = NULL, keep_failures = 0, keep_failed_at = 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 }; @@ -241,14 +409,17 @@ export function pinFile(ctx: StoreContext, ref: NodeRef): { kept: boolean } { 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); + ctx.db.run('UPDATE file_node SET pinned = 0, pinned_at = NULL 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); + if (row.stale_blob_id) dropBlob(ctx.db, ref.jmap, row.stale_blob_id); + } }); } 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]); + ctx.db.run('UPDATE file_node SET opened_at = ?, keep_error = NULL, keep_failures = 0, keep_failed_at = 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 }; @@ -267,7 +438,10 @@ export interface KeptFileStatus { size: number | null; pinned: boolean; openedAt: number | null; + /** Its current content is here. */ kept: boolean; + /** Only the version before is here, until the current one arrives. */ + stale: boolean; /** Why it isn't kept: tooLarge, overLimit, notAllowed or download. */ error: string | null; } @@ -275,7 +449,7 @@ export interface KeptFileStatus { /** 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( - `SELECT jmap, id, name, kind, blob_id, size, json, pinned, opened_at, keep_error FROM file_node + `SELECT ${ROW_COLUMNS} FROM file_node WHERE pinned = 1 OR opened_at IS NOT NULL ORDER BY pinned DESC, opened_at DESC, name`, ); return { @@ -287,6 +461,7 @@ export function keptStatus(ctx: StoreContext): { files: KeptFileStatus[]; bytes: 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), + stale: row.stale_blob_id !== null, error: row.keep_error, })), bytes: keptBlobs(ctx.db).reduce((sum, blob) => sum + Number(blob.size), 0), diff --git a/lib/store/files/methods.ts b/lib/store/files/methods.ts index 0945a33eb..49309a8b0 100644 --- a/lib/store/files/methods.ts +++ b/lib/store/files/methods.ts @@ -12,10 +12,14 @@ function jmapOf(ctx: MethodContext, args: Record): 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. */ +/** + * The last state from the server. Throws until a first full pass finished, so + * an answer never passes off part of a tree as the whole. A later resync + * (phase `list`) goes on answering from the copy it's refreshing. + */ 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'); + if (!row || (!row.state && row.phase !== 'list')) throw new MethodError('serverUnavailable', 'Files not kept yet'); return row.state; } diff --git a/lib/store/files/records.ts b/lib/store/files/records.ts index fd5cbcb51..40d624460 100644 --- a/lib/store/files/records.ts +++ b/lib/store/files/records.ts @@ -29,6 +29,12 @@ export function writeNode(db: StorageAdapter, jmap: string, node: WireNode, gen: 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, + keep_failures = CASE WHEN blob_id IS excluded.blob_id THEN keep_failures ELSE 0 END, + stale_blob_id = CASE + WHEN blob_id IS excluded.blob_id THEN stale_blob_id + WHEN EXISTS (SELECT 1 FROM file_blob b WHERE b.jmap = file_node.jmap AND b.blob_id = file_node.blob_id AND b.complete = 1) THEN blob_id + ELSE stale_blob_id + END, blob_id = excluded.blob_id`, [ jmap, diff --git a/lib/store/files/schema.ts b/lib/store/files/schema.ts index 9a3f20e35..89a6e8cb3 100644 --- a/lib/store/files/schema.ts +++ b/lib/store/files/schema.ts @@ -17,20 +17,29 @@ export const FILE_MIGRATIONS: readonly Migration[] = [ json TEXT NOT NULL, gen INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, + pinned_at INTEGER, -- 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, + -- Failed downloads in a row, and when the last one was. + keep_failures INTEGER NOT NULL DEFAULT 0, + keep_failed_at INTEGER, + -- The kept content of the version before blob_id, until blob_id is kept. + stale_blob_id 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 INDEX file_node_stale ON file_node (jmap, stale_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, + -- 0 while its chunks are still coming in. Nothing reads it until it's 1. + complete INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (jmap, blob_id) ) WITHOUT ROWID; -- In pieces: a whole file bound at once would grow the WASM heap by its diff --git a/lib/store/files/sync.ts b/lib/store/files/sync.ts index cc9d99918..5b853c229 100644 --- a/lib/store/files/sync.ts +++ b/lib/store/files/sync.ts @@ -6,30 +6,32 @@ * 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. + * read by id. The cursor is in sync_state, so a reload continues. Query pages + * go from the last node read (`anchor`): a position moves when an earlier node + * is deleted, and a skipped node would be swept at the end. */ import type { SyncContext, Syncer } from '../area'; import { runChangesLoop } from '../engine/changes'; +import { requestLimits } from '../engine/limits'; 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 MOST_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); + return requestLimits(ctx.session).maxObjectsInGet; } async function fetchNodes(ctx: SyncContext, jmap: string, ids: string[]): Promise { const size = getBatch(ctx); + const perRequest = Math.min(MOST_GETS_PER_REQUEST, requestLimits(ctx.session).maxCallsInRequest); 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) { + for (let i = 0; i < perRequest && 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); @@ -38,14 +40,16 @@ async function fetchNodes(ctx: SyncContext, jmap: string, ids: string[]): Promis return nodes; } -interface ListCursor { step: 'all' | 'query' | 'parents'; position: number; gen: number; captured: string } +interface ListCursor { step: 'all' | 'query' | 'parents'; position: number; anchor: string | null; gen: number; captured: string } async function startFullList(ctx: SyncContext, jmap: string): Promise { 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 } })); + const cursor: ListCursor = { step: 'all', 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, 'FileNode').state ? 'list' : 'first'; + ctx.db.transaction(() => ctx.setSyncState(jmap, 'FileNode', { state: null, phase, cursor: { ...cursor } })); return cursor; } @@ -76,12 +80,24 @@ async function runFullList(ctx: SyncContext, jmap: string, start: ListCursor): P 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 from = cursor.anchor === null ? { position: 0 } : { anchor: cursor.anchor, anchorOffset: 1 }; + const response = await ctx.transport.request([['FileNode/query', { accountId: jmap, filter: {}, ...from, limit: QUERY_PAGE, calculateTotal: true }, 'q']], FILES_USING); + let query: { ids: string[]; position?: number; total?: number }; + try { + [, query] = responseFor(response, 'q'); + } catch (error) { + if (!(error instanceof JmapMethodError && error.type === 'anchorNotFound')) throw error; + // The node it went from is gone. Going through again costs requests, not nodes. + cursor.anchor = null; + cursor.position = 0; + saveStep(ctx, jmap, [], cursor); + continue; + } + const ids = query.ids; 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; + cursor.position = Number(query.position ?? 0) + ids.length; + if (ids.length > 0) cursor.anchor = ids[ids.length - 1]; // 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); @@ -140,7 +156,7 @@ export const filesSyncer: Syncer = { 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; + 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); @@ -154,7 +170,8 @@ export const filesSyncer: Syncer = { 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); + // After the fetching: a file's old content stays until the new one is in. + ctx.db.transaction(() => dropOrphanBlobs(ctx.db)); }, }; From 71bd7f7da8f488cff2b9f27bca1b66db11525957 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:46:46 +0100 Subject: [PATCH 33/35] fix: show the desktop app's offline warning once --- components/offline/__tests__/account-storage.test.tsx | 7 ++++--- components/offline/account-storage.tsx | 2 -- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/components/offline/__tests__/account-storage.test.tsx b/components/offline/__tests__/account-storage.test.tsx index 2d214ad4e..429ed93ca 100644 --- a/components/offline/__tests__/account-storage.test.tsx +++ b/components/offline/__tests__/account-storage.test.tsx @@ -1,5 +1,5 @@ import 'fake-indexeddb/auto'; -import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +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(() => ({ @@ -311,10 +311,11 @@ describe('in the desktop app', () => { it('says what the risk is there, and only there', () => { render(); - expect(screen.queryByTestId('offline-desktop-warning')).toBeNull(); + expect(screen.getByTestId('offline-warning').textContent).toBe('warning'); + cleanup(); (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 }; render(); - expect(screen.getByTestId('offline-desktop-warning').textContent).toBe('warning_desktop'); + expect(screen.getAllByTestId('offline-warning').map((node) => node.textContent)).toEqual(['warning_desktop']); }); }); diff --git a/components/offline/account-storage.tsx b/components/offline/account-storage.tsx index a75a09197..d58d07a1d 100644 --- a/components/offline/account-storage.tsx +++ b/components/offline/account-storage.tsx @@ -7,7 +7,6 @@ import { SettingItem, Select, ToggleSwitch } from '@/components/settings/setting import { OrgLockedNote } from '@/components/settings/org-locked-note'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { isDesktopApp } from '@/lib/desktop/bridge'; 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'; @@ -144,7 +143,6 @@ export function AccountStorage({ account, primary, heading }: { account: string; }} /> - {isDesktopApp() &&

    {tStorage('warning_desktop')}

    } {storeReset && (

    From 54852df7380f3cf9e30d9196030cbca125cfd97e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:12:19 +0100 Subject: [PATCH 34/35] test: stop a pinned download at the organisation limit when the size is missing --- lib/store/__tests__/files.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/store/__tests__/files.test.ts b/lib/store/__tests__/files.test.ts index fe2dd5b39..c615a43d2 100644 --- a/lib/store/__tests__/files.test.ts +++ b/lib/store/__tests__/files.test.ts @@ -523,6 +523,17 @@ describe('pinned files', () => { expect((await engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ kept: false, error: 'overLimit' }); }); + it('stops a pinned download at the organisation limit, whatever the size says', async () => { + const server = new FakeFiles(); + server.partBytes = 10; + const big = server.addFile('big', null, new Uint8Array(1000), { size: null }); + const { engine } = await synced(server, { orgMaxFileBytes: 50 }); + await engine.areaCall('files.pin', ref(big.id)); + await engine.requestSync(); + expect(server.delivered.get(big.blobId as string)).toBeLessThanOrEqual(60); + expect((await engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ kept: false, error: 'tooLarge' }); + }); + it('writes a download in pieces and gives nothing out until it is complete', async () => { const server = new FakeFiles(); const file = server.addFile('big', null, new Uint8Array(3 * 1024 * 1024).fill(7)); From 3cefd91dab5eca448967fa0c2899ab135462e1f8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:23:34 +0100 Subject: [PATCH 35/35] fix: close the keystore bridge type the policies merge left open --- lib/desktop/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 7e8532aee..83773d92a 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -184,6 +184,8 @@ export interface DesktopKeystoreBridge { revokeAccess: () => Promise; /** Takes back this session's grants, for the next account. Absent on older shells. */ clearGrants?: () => Promise; +} + export interface DesktopPolicyBridge { /** What's in force now. Synchronous: a page can read it in its first render. */ get: () => PolicySnapshot;