From f6db7ea53a7c925a46fc7fcf79375df0e8f33216 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:02:10 +0100 Subject: [PATCH] fix: keep pinned and unread-first lists off a full sort, and stop syncing when the disk is full --- components/offline/offline-host.tsx | 3 + lib/offline/offline-mode.ts | 12 ++ lib/store/__tests__/mail-speed.test.ts | 276 +++++++++++++++++++++++++ lib/store/area.ts | 3 + lib/store/engine/account-engine.ts | 64 ++++-- lib/store/mail/methods.ts | 159 ++++++++++++-- lib/store/storage.ts | 10 + locales/ar/common.json | 3 +- locales/ca/common.json | 3 +- locales/cs/common.json | 3 +- locales/da/common.json | 3 +- locales/de/common.json | 3 +- locales/en/common.json | 3 +- locales/es/common.json | 3 +- locales/et/common.json | 3 +- locales/fa/common.json | 3 +- locales/fr/common.json | 3 +- locales/he/common.json | 3 +- locales/hu/common.json | 3 +- locales/it/common.json | 3 +- locales/ja/common.json | 3 +- locales/ko/common.json | 3 +- locales/lv/common.json | 3 +- locales/mn/common.json | 3 +- locales/nb/common.json | 3 +- locales/nl/common.json | 3 +- locales/pl/common.json | 3 +- locales/pt/common.json | 3 +- locales/ro/common.json | 3 +- locales/ru/common.json | 3 +- locales/sk/common.json | 3 +- locales/th/common.json | 3 +- locales/tr/common.json | 3 +- locales/uk/common.json | 3 +- locales/vi/common.json | 3 +- locales/zh-TW/common.json | 3 +- locales/zh/common.json | 3 +- 37 files changed, 556 insertions(+), 61 deletions(-) create mode 100644 lib/store/__tests__/mail-speed.test.ts diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx index 6797648d5..13e675950 100644 --- a/components/offline/offline-host.tsx +++ b/components/offline/offline-host.tsx @@ -51,6 +51,9 @@ export function OfflineHost() { case 'conflict': toast.warning(tConflictsRef.current('toast', { count: notice.count })); break; + case 'storage-failed': + toast.warning(tConflictsRef.current('storage_failed'), { duration: 0 }); + break; case 'refused-delete': toast.warning(tr('toast.refused_delete')); break; diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 6b20ac65d..4b0220964 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -26,6 +26,7 @@ import { accountSubjectPending, useAccountSubjects } from '@/lib/org/account-sub import { labelsPolicyFor } from '@/lib/outgoing/classification'; import { getSendGuardSource, guardState, guardsSending } from '@/lib/plugin-send-guards'; import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync'; +import { STORAGE_ERROR_AREA } 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'; @@ -62,6 +63,7 @@ export type OfflineNotice = | { kind: 'held'; subject: string } | { kind: 'lost'; subjects: string[] } | { kind: 'conflict'; count: number } + | { kind: 'storage-failed' } | { kind: 'refused-delete' } | { kind: 'refused-other' } | { kind: 'forbidden' } @@ -131,6 +133,10 @@ function onlineClientFor(account: string) { // ---- The store's way to this tab ---- +/** A full disk stays full: once in a while is enough to say so. */ +const STORAGE_NOTICE_MS = 30 * 60_000; +let lastStorageNotice = 0; + const storeHooks: StorePageHooks = { inputs(account) { const client = onlineClientFor(account); @@ -152,6 +158,12 @@ const storeHooks: StorePageHooks = { useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at } })); void refreshUsage(); } + if (event.type === 'error' && event.area === STORAGE_ERROR_AREA) { + const now = Date.now(); + if (now - lastStorageNotice < STORAGE_NOTICE_MS) return; + lastStorageNotice = now; + notify({ kind: 'storage-failed' }); + } }, }; diff --git a/lib/store/__tests__/mail-speed.test.ts b/lib/store/__tests__/mail-speed.test.ts new file mode 100644 index 000000000..3e9212e9a --- /dev/null +++ b/lib/store/__tests__/mail-speed.test.ts @@ -0,0 +1,276 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it } from 'vitest'; +import { DEFAULT_SCOPE, STORAGE_ERROR_AREA, type StoreEvent } from '../area'; +import { AccountEngine } from '../engine/account-engine'; +import { TransportError } from '../engine/transport'; +import { mailArea } from '../mail'; +import { outboxArea } from '../outbox'; +import { isStorageFailure, type SqlValue, type StorageAdapter } from '../storage'; +import { FAKE_SESSION } from './helpers/fake-jmap'; +import { FakeJmapMail } from './helpers/fake-jmap-mail'; +import { openMemoryStore } from './helpers/node-adapter'; + +/** Records the SQL of a query, so its plan can be read. */ +function spying(db: StorageAdapter) { + const seen: Array<{ sql: string; params: readonly SqlValue[] }> = []; + const spied: StorageAdapter = { + exec: (sql) => db.exec(sql), + run: (sql, params) => db.run(sql, params), + all: (sql: string, params?: readonly SqlValue[]) => { + seen.push({ sql, params: params ?? [] }); + return db.all(sql, params); + }, + get: (sql: string, params?: readonly SqlValue[]) => { + seen.push({ sql, params: params ?? [] }); + return db.get(sql, params); + }, + transaction: (fn) => db.transaction(fn), + close: () => db.close(), + }; + return { spied, seen }; +} + +function setup(emails: Array<{ id: string; keywords?: Record; mailboxIds?: Record }>) { + const server = new FakeJmapMail({ mailboxes: [{ id: 'inbox', role: 'inbox' }, { id: 'other' }] }); + emails.forEach((email, i) => server.addEmail({ + keywords: {}, + mailboxIds: { inbox: true }, + receivedAt: new Date(Date.UTC(2026, 9, 1) + i * 60_000).toISOString(), + ...email, + })); + const raw = openMemoryStore(); + const { spied, seen } = spying(raw); + const engine = new AccountEngine({ + account: 'a', db: spied, session: FAKE_SESSION, transport: server, areas: [mailArea, outboxArea], + scope: { ...DEFAULT_SCOPE, days: null, attachments: false }, sleep: async () => {}, + }); + return { server, raw, seen, engine }; +} + +const NEWEST = { property: 'receivedAt', isAscending: false }; +const PINNED = { property: 'hasKeyword', keyword: '$pinned', isAscending: false }; +const UNREAD_FIRST = { property: 'hasKeyword', keyword: '$seen', isAscending: true }; +const IN_INBOX = { inMailbox: 'inbox' }; + +async function ids(engine: AccountEngine, args: Record): Promise { + const answer = await engine.answer([['Email/query', args, 'q']]); + return answer?.methodResponses[0][1].ids; +} + +async function plans(t: ReturnType, args: Record) { + t.seen.length = 0; + await t.engine.answer([['Email/query', args, 'q']]); + const reads = t.seen.filter(({ sql }) => /^SELECT (e\.id|COUNT)/.test(sql) && !sql.includes('sync_state')); + return reads.map(({ sql, params }) => ({ + sql, + plan: t.raw.all<{ detail: string }>(`EXPLAIN QUERY PLAN ${sql}`, params).map((row) => row.detail).join('; '), + })); +} + +describe('pinned and unread first', () => { + const MAIL: Array<{ id: string; keywords?: Record; mailboxIds?: Record }> = [ + { id: 'old-read', keywords: { $seen: true } }, + { id: 'old-unread' }, + { id: 'pin-read', keywords: { $seen: true, $pinned: true } }, + { id: 'mid-read', keywords: { $seen: true } }, + { id: 'pin-unread', keywords: { $pinned: true } }, + { id: 'new-unread' }, + { id: 'new-read', keywords: { $seen: true } }, + { id: 'elsewhere', keywords: { $pinned: true }, mailboxIds: { other: true } }, + ]; + + let t: ReturnType; + + beforeEach(async () => { + t = setup(MAIL); + await t.engine.requestSync(); + }); + + it('comes out the same as sorting the whole folder would', async () => { + expect(await ids(t.engine, { filter: IN_INBOX, sort: [PINNED, NEWEST] })) + .toEqual(['pin-unread', 'pin-read', 'new-read', 'new-unread', 'mid-read', 'old-unread', 'old-read']); + expect(await ids(t.engine, { filter: IN_INBOX, sort: [UNREAD_FIRST, NEWEST] })) + .toEqual(['new-unread', 'pin-unread', 'old-unread', 'new-read', 'mid-read', 'pin-read', 'old-read']); + expect(await ids(t.engine, { filter: IN_INBOX, sort: [PINNED, UNREAD_FIRST, NEWEST] })) + .toEqual(['pin-unread', 'pin-read', 'new-unread', 'old-unread', 'new-read', 'mid-read', 'old-read']); + // No date level at all: the same. + expect(await ids(t.engine, { filter: IN_INBOX, sort: [PINNED] })) + .toEqual(['pin-unread', 'pin-read', 'new-read', 'new-unread', 'mid-read', 'old-unread', 'old-read']); + }); + + it('pages across the groups', async () => { + const whole = await ids(t.engine, { filter: IN_INBOX, sort: [PINNED, NEWEST] }); + for (let position = 0; position < whole.length + 2; position += 1) { + for (const limit of [1, 2, 3, 50]) { + expect(await ids(t.engine, { filter: IN_INBOX, sort: [PINNED, NEWEST], position, limit })).toEqual(whole.slice(position, position + limit)); + } + } + }); + + it('keeps conditions, across the whole account too', async () => { + expect(await ids(t.engine, { filter: { operator: 'AND', conditions: [IN_INBOX, { notKeyword: '$seen' }] }, sort: [PINNED, NEWEST] })) + .toEqual(['pin-unread', 'new-unread', 'old-unread']); + expect(await ids(t.engine, { sort: [PINNED, NEWEST], limit: 2 })).toEqual(['elsewhere', 'pin-unread']); + expect(await ids(t.engine, { filter: { text: 'nothing-matches-this' }, sort: [PINNED, NEWEST] })).toEqual([]); + }); + + it('reads the comparator the way the client sends it for a server that flips it', async () => { + t.engine.setInvertedKeywordSort(true); + expect((await ids(t.engine, { filter: IN_INBOX, sort: [{ ...PINNED, isAscending: true }, NEWEST], limit: 2 }))).toEqual(['pin-unread', 'pin-read']); + expect((await ids(t.engine, { filter: IN_INBOX, sort: [{ ...PINNED, isAscending: false }, NEWEST], limit: 1 }))).toEqual(['new-read']); + }); + + it('answers the client\'s polarity probe the way a server would', async () => { + const asc = await ids(t.engine, { sort: [{ property: 'hasKeyword', keyword: '$seen', isAscending: true }, NEWEST], limit: 1 }); + const desc = await ids(t.engine, { sort: [{ property: 'hasKeyword', keyword: '$seen', isAscending: false }, NEWEST], limit: 1 }); + expect(asc).toEqual(['elsewhere']); + expect(desc).toEqual(['new-read']); + }); + + it('never sorts the folder', async () => { + for (const sort of [[PINNED, NEWEST], [UNREAD_FIRST, NEWEST], [PINNED, UNREAD_FIRST, NEWEST]]) { + const reads = await plans(t, { filter: IN_INBOX, sort, limit: 5 }); + expect(reads.length).toBeGreaterThan(0); + for (const { plan, sql } of reads) { + // A rare keyword's own rows may be sorted. The folder's never are. + if (sql.includes('email_keyword r')) expect(plan).toContain('email_keyword_lookup'); + else expect(plan).not.toContain('TEMP B-TREE'); + } + } + }); + + it('reads a rare keyword off its index first, and the rest off the folder\'s', async () => { + const reads = await plans(t, { filter: IN_INBOX, sort: [PINNED, NEWEST], limit: 3 }); + const pages = reads.filter(({ sql }) => sql.startsWith('SELECT e.id')); + expect(pages[0].plan).toContain('email_keyword_lookup'); + expect(pages[1].plan).toContain('email_mailbox_list'); + expect(pages[1].plan).not.toContain('TEMP B-TREE'); + }); + + it('counts what it skips without reading a message', async () => { + const reads = await plans(t, { filter: IN_INBOX, sort: [PINNED, NEWEST], position: 3, limit: 1 }); + const counts = reads.filter(({ sql }) => sql.startsWith('SELECT COUNT')); + expect(counts.length).toBeGreaterThan(0); + for (const { plan } of counts) expect(plan).not.toMatch(/\bSCAN e\b|SEARCH e\b/); + }); + + it('leaves other orders to a full sort, and they still work', async () => { + expect(await ids(t.engine, { filter: IN_INBOX, sort: [PINNED, { property: 'size', isAscending: true }, NEWEST], limit: 2 })).toHaveLength(2); + expect(await ids(t.engine, { filter: IN_INBOX, sort: [NEWEST], collapseThreads: true, limit: 2 })).toHaveLength(2); + }); +}); + +describe('calculateTotal', () => { + it('counts folder conditions off the index and the keyword rows, reading no message', async () => { + const t = setup([{ id: 'a' }, { id: 'b', keywords: { $seen: true } }]); + await t.engine.requestSync(); + t.seen.length = 0; + const answer = await t.engine.answer([['Email/query', { + filter: { operator: 'AND', conditions: [IN_INBOX, { notKeyword: '$seen' }, { after: '2020-01-01T00:00:00Z' }] }, + sort: [NEWEST], + limit: 1, + calculateTotal: true, + }, 'q']]); + expect(answer?.methodResponses[0][1].total).toBe(1); + const count = t.seen.find(({ sql }) => sql.startsWith('SELECT COUNT'))!; + const plan = t.raw.all<{ detail: string }>(`EXPLAIN QUERY PLAN ${count.sql}`, count.params).map((row) => row.detail).join('; '); + expect(plan).toContain('email_mailbox_list'); + expect(plan).not.toMatch(/\bSCAN e\b|SEARCH e\b/); + }); + + it('still reads the message for a condition only it can answer', async () => { + const t = setup([{ id: 'a', size: 5 } as never, { id: 'b', size: 500 } as never]); + await t.engine.requestSync(); + const answer = await t.engine.answer([['Email/query', { filter: { operator: 'AND', conditions: [IN_INBOX, { minSize: 100 }] }, calculateTotal: true }, 'q']]); + expect(answer?.methodResponses[0][1].total).toBe(1); + }); +}); + +describe('a failed write', () => { + function failing(now: { at: number }) { + const server = new FakeJmapMail(); + for (let i = 0; i < 400; i += 1) server.addEmail({ bodyValues: { 1: { value: 'x'.repeat(2000) } } }); + const db = openMemoryStore(); + const events: StoreEvent[] = []; + const engine = new AccountEngine({ + account: 'a', db, session: FAKE_SESSION, transport: server, areas: [mailArea, outboxArea], + scope: { ...DEFAULT_SCOPE, days: null, attachments: false }, sleep: async () => {}, + emit: (event) => events.push(event), now: () => now.at, + }); + // A real full disk, as SQLite reports it. + const pages = Number(db.get<{ page_count: number }>('PRAGMA page_count')?.page_count); + db.exec(`PRAGMA max_page_count = ${pages + 40}`); + return { server, db, events, engine }; + } + + const storageErrors = (events: StoreEvent[]) => events.filter((event) => event.type === 'error' && event.area === STORAGE_ERROR_AREA); + + it('tells the page, says no "synced", and doesn\'t try again on every push', async () => { + const now = { at: 1_000_000 }; + const t = failing(now); + await t.engine.requestSync(); + expect(storageErrors(t.events)).toHaveLength(1); + expect(t.events.some((event) => event.type === 'synced')).toBe(false); + + const requests = t.server.requests; + t.engine.noteStateChange({ acc1: { Email: 'whatever' } }); + await t.engine.requestSync(); + await t.engine.requestSync(); + expect(t.server.requests).toBe(requests); + expect(storageErrors(t.events)).toHaveLength(1); + }); + + it('tries again after a while, longer each time, and carries on once there\'s room', async () => { + const now = { at: 1_000_000 }; + const t = failing(now); + await t.engine.requestSync(); + + now.at += 61_000; + await t.engine.requestSync(); + expect(storageErrors(t.events)).toHaveLength(2); + + // Now it waits two minutes. + now.at += 61_000; + const requests = t.server.requests; + await t.engine.requestSync(); + expect(t.server.requests).toBe(requests); + + now.at += 61_000; + t.db.exec('PRAGMA max_page_count = 1000000'); + await t.engine.requestSync(); + expect(t.events.some((event) => event.type === 'synced')).toBe(true); + expect(t.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM email')?.n).toBe(400); + }); + + it('lets the user make room by keeping less, without waiting', async () => { + const now = { at: 1_000_000 }; + const t = failing(now); + await t.engine.requestSync(); + const requests = t.server.requests; + t.engine.setScope({ ...DEFAULT_SCOPE, days: 1 }); + await t.engine.requestSync(); + expect(t.server.requests).toBeGreaterThan(requests); + }); + + it('reports a change that couldn\'t be kept, and fails it rather than pretending', async () => { + const now = { at: 1_000_000 }; + const t = failing(now); + t.db.exec('PRAGMA max_page_count = 1000000'); + await t.engine.requestSync(); + const pages = Number(t.db.get<{ page_count: number }>('PRAGMA page_count')?.page_count); + t.db.exec(`PRAGMA max_page_count = ${pages}`); + const big = 'x'.repeat(5_000_000); + await expect(t.engine.areaCall('outbox.queueSend', { summary: { subject: big, to: [] }, item: { kind: 'email', args: [] } })).rejects.toThrow(/full/); + expect(storageErrors(t.events)).toHaveLength(1); + t.db.exec('PRAGMA max_page_count = 1000000'); + expect((await t.engine.areaCall('outbox.read', {}) as { entries: unknown[] }).entries).toEqual([]); + }); + + it('knows these errors from others', () => { + expect(isStorageFailure(new Error('database or disk is full'))).toBe(true); + expect(isStorageFailure(new Error('SQLITE_FULL: sqlite3 result code 13'))).toBe(true); + expect(isStorageFailure(Object.assign(new Error('x'), { name: 'QuotaExceededError' }))).toBe(true); + expect(isStorageFailure(new Error('no such table: x'))).toBe(false); + expect(isStorageFailure(new TransportError('network', 'Failed to fetch'))).toBe(false); + }); +}); diff --git a/lib/store/area.ts b/lib/store/area.ts index d40743b05..9393d05a4 100644 --- a/lib/store/area.ts +++ b/lib/store/area.ts @@ -28,6 +28,9 @@ export const DEFAULT_SCOPE: StoreScope = { maxTotalBytes: 2 * 1024 * 1024 * 1024, }; +/** The `area` of an error event for a write that failed: a full disk, the browser's quota. */ +export const STORAGE_ERROR_AREA = 'storage'; + export interface SyncStateRow { state: string | null; /** The newest state the server told us about, to know when a run is caught up. */ diff --git a/lib/store/engine/account-engine.ts b/lib/store/engine/account-engine.ts index bbe0ab51a..acedaf252 100644 --- a/lib/store/engine/account-engine.ts +++ b/lib/store/engine/account-engine.ts @@ -4,8 +4,8 @@ * the UI. Nothing here touches the DOM, so tests run it on node:sqlite. */ -import { MethodError, type AreaCall, type MethodHandler, type RecordOverlay, type StoreArea, type StoreContext, type StoreEvent, type StoreScope, type SyncContext, type SyncStateRow, type Syncer } from '../area'; -import { migrate, type StorageAdapter } from '../storage'; +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 { isStorageFailure, migrate, type StorageAdapter } from '../storage'; import { answerRequest, buildMethodTable } from './responder'; import { TransportError, type JmapResponse, type JmapTransport, type MethodCall, type StoreSession } from './transport'; import { CORE_MIGRATIONS, metaGet, metaSet } from './core-schema'; @@ -26,6 +26,9 @@ export interface AccountEngineOptions { const RATE_RETRIES = 5; 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; export class AccountEngine { readonly account: string; @@ -45,6 +48,9 @@ export class AccountEngine { private online = true; private running: Promise | null = null; private again = false; + /** A write failed (full disk, quota): no sync before this time, however much the server pushes. */ + private storageBlockedUntil = 0; + private storageFailures = 0; constructor(options: AccountEngineOptions) { this.account = options.account; @@ -85,6 +91,8 @@ export class AccountEngine { setScope(scope: StoreScope): void { this.scope = scope; + // The user may just have made room by keeping less. + this.storageBlockedUntil = 0; metaSet(this.db, 'scope', JSON.stringify(scope)); } @@ -139,7 +147,7 @@ export class AccountEngine { /** Starts a run, or asks the running one to go again when it's done. */ requestSync(): Promise { - if (this.closed) return Promise.resolve(); + if (this.closed || this.now() < this.storageBlockedUntil) return Promise.resolve(); if (this.running) { this.again = true; return this.running; @@ -154,7 +162,7 @@ export class AccountEngine { do { this.again = false; await this.runOnce(); - } while (this.again && !this.closed && this.online); + } while (this.again && !this.closed && this.online && this.now() >= this.storageBlockedUntil); } private async runOnce(): Promise { @@ -169,6 +177,7 @@ export class AccountEngine { if (!(await this.runSyncer(ctx, syncer, jmap))) return; } } + this.storageFailures = 0; if (!this.closed && this.online) this.emitter({ type: 'synced', account: this.account, at: this.now() }); } @@ -186,6 +195,11 @@ export class AccountEngine { continue; } } + // The others would fail on the same disk: the run ends, and sync waits. + if (isStorageFailure(error)) { + this.noteStorageFailure(error); + return false; + } // One part failing doesn't hold up the others. It runs again next time. this.emitter({ type: 'error', @@ -198,6 +212,18 @@ export class AccountEngine { } } + private noteStorageFailure(error: unknown): void { + this.storageFailures += 1; + const wait = Math.min(STORAGE_BACKOFF_MS * 2 ** (this.storageFailures - 1), STORAGE_BACKOFF_MAX_MS); + this.storageBlockedUntil = this.now() + wait; + this.emitter({ + type: 'error', + account: this.account, + area: STORAGE_ERROR_AREA, + message: error instanceof Error ? error.message : String(error), + }); + } + private syncContext(): SyncContext { return { account: this.account, @@ -230,22 +256,32 @@ export class AccountEngine { async areaCall(name: string, params: unknown): Promise { const call = this.calls.get(name); if (!call) throw new Error(`Unknown store call ${name}`); - return call(this.storeContext(), params as never); + try { + return await call(this.storeContext(), params as never); + } catch (error) { + if (isStorageFailure(error)) this.noteStorageFailure(error); + throw error; + } } /** An answer from the device. Null when nothing in the request could be answered. */ async answer(calls: MethodCall[]): Promise { if (this.closed) throw new MethodError('serverUnavailable', 'The local store is closed'); const primary = this.session.primaryAccounts['urn:ietf:params:jmap:mail'] ?? Object.keys(this.session.accounts)[0]; - return answerRequest( - { - ...this.storeContext(), - defaultJmap: primary, - invertedKeywordSort: this.invertedKeywordSort, - }, - this.methods, - calls, - ); + try { + return await answerRequest( + { + ...this.storeContext(), + defaultJmap: primary, + invertedKeywordSort: this.invertedKeywordSort, + }, + this.methods, + calls, + ); + } catch (error) { + if (isStorageFailure(error)) this.noteStorageFailure(error); + throw error; + } } /** Stops syncing and waits for the current batch. The database stays open for the caller to close. */ diff --git a/lib/store/mail/methods.ts b/lib/store/mail/methods.ts index 163f7e9c2..b5e80c9fb 100644 --- a/lib/store/mail/methods.ts +++ b/lib/store/mail/methods.ts @@ -30,12 +30,16 @@ function likeTerm(value: unknown): string { /** * `drivenBy` is the folder the query already starts from: repeating that - * condition would look the membership up again for every row. + * condition would look the membership up again for every row. With a folder row + * (`d`) at hand, conditions read it instead of the message's own row, which + * saves reading the message at all when nothing else needs it. */ -function filterSql(filter: Filter, jmap: string, drivenBy: string | null = null): Sql { +function filterSql(filter: Filter, jmap: string, drivenBy: string | null = null, folderRow = drivenBy !== null): Sql { + const rowid = folderRow ? 'd.email_rowid' : 'e.rowid'; + const receivedAt = folderRow ? 'd.received_at' : 'e.received_at'; if (typeof filter.operator === 'string') { const conditions = Array.isArray(filter.conditions) ? filter.conditions as Filter[] : []; - const parts = conditions.map((c) => filterSql(c, jmap, filter.operator === 'AND' ? drivenBy : null)); + const parts = conditions.map((c) => filterSql(c, jmap, filter.operator === 'AND' ? drivenBy : null, folderRow)); 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 }; @@ -49,29 +53,29 @@ function filterSql(filter: Filter, jmap: string, drivenBy: string | null = null) switch (key) { case 'inMailbox': if (value === drivenBy) break; - conditions.push('EXISTS (SELECT 1 FROM email_mailbox m WHERE m.email_rowid = e.rowid AND m.mailbox_id = ?)'); + conditions.push(`EXISTS (SELECT 1 FROM email_mailbox m WHERE m.email_rowid = ${rowid} AND m.mailbox_id = ?)`); params.push(String(value)); break; case 'inMailboxOtherThan': { const ids = (value as string[]).map(String); - conditions.push(`EXISTS (SELECT 1 FROM email_mailbox m WHERE m.email_rowid = e.rowid AND m.mailbox_id NOT IN (${ids.map(() => '?').join(',') || "''"}))`); + conditions.push(`EXISTS (SELECT 1 FROM email_mailbox m WHERE m.email_rowid = ${rowid} AND m.mailbox_id NOT IN (${ids.map(() => '?').join(',') || "''"}))`); params.push(...ids); break; } case 'hasKeyword': - conditions.push('EXISTS (SELECT 1 FROM email_keyword k WHERE k.email_rowid = e.rowid AND k.keyword = ?)'); + conditions.push(`EXISTS (SELECT 1 FROM email_keyword k WHERE k.email_rowid = ${rowid} AND k.keyword = ?)`); params.push(String(value).toLowerCase()); break; case 'notKeyword': - conditions.push('NOT EXISTS (SELECT 1 FROM email_keyword k WHERE k.email_rowid = e.rowid AND k.keyword = ?)'); + conditions.push(`NOT EXISTS (SELECT 1 FROM email_keyword k WHERE k.email_rowid = ${rowid} AND k.keyword = ?)`); params.push(String(value).toLowerCase()); break; case 'after': - conditions.push('e.received_at >= ?'); + conditions.push(`${receivedAt} >= ?`); params.push(Date.parse(String(value)) || 0); break; case 'before': - conditions.push('e.received_at < ?'); + conditions.push(`${receivedAt} < ?`); params.push(Date.parse(String(value)) || 0); break; case 'minSize': @@ -175,6 +179,121 @@ function drivingMailbox(filter: Filter | undefined): string | null { return null; } +/** One step of a "pinned first" or "unread first" order. */ +interface KeywordLevel { keyword: string; first: boolean } + +/** A keyword this rare is read off its own index, then sorted. Commoner ones are checked along the folder. */ +const RARE_KEYWORD_ROWS = 5000; +const MAX_KEYWORD_LEVELS = 4; + +/** + * The keyword comparators of an order that goes on newest first, which is what + * the pinned and unread-first lists send. Null for any other order. + */ +function keywordLevels(ctx: MethodContext, sort: Comparator[]): KeywordLevel[] | null { + const levels: KeywordLevel[] = []; + let next = 0; + for (; sort[next]?.property === 'hasKeyword'; next += 1) { + const ascending = sort[next].isAscending !== false; + levels.push({ keyword: String(sort[next].keyword ?? '').toLowerCase(), first: ctx.invertedKeywordSort ? ascending : !ascending }); + } + if (levels.length === 0 || levels.length > MAX_KEYWORD_LEVELS) return null; + return isNewestFirst(sort.slice(next)) ? levels : null; +} + +/** + * A page of an order by keywords and then date, without sorting the folder: the + * order splits into groups (pinned, then the rest), and each group is read in + * date order off the folder's index until the page is full. + */ +function keywordOrderedPage( + ctx: MethodContext, + jmap: string, + box: string | null, + where: Sql, + levels: readonly KeywordLevel[], + position: number, + limit: number, +): string[] { + const rowid = box ? 'd.email_rowid' : 'e.rowid'; + const rows = new Map(); + const rowsOf = (keyword: string): number => { + let n = rows.get(keyword); + if (n === undefined) { + n = Number(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM email_keyword INDEXED BY email_keyword_lookup WHERE keyword = ?', [keyword])?.n ?? 0); + rows.set(keyword, n); + } + return n; + }; + + const groupQuery = (group: ReadonlyArray<{ keyword: string; has: boolean }>) => { + const rare = group + .filter((level) => level.has && rowsOf(level.keyword) <= RARE_KEYWORD_ROWS) + .sort((a, b) => rowsOf(a.keyword) - rowsOf(b.keyword))[0]; + const checked = group.filter((level) => level !== rare); + const sql = [ + `${box ? 'd.jmap = ?' : 'e.jmap = ?'}${box && !rare ? ' AND d.mailbox_id = ?' : ''}`, + ...checked.map((level) => `${level.has ? '' : 'NOT '}EXISTS (SELECT 1 FROM email_keyword c WHERE c.email_rowid = ${rowid} AND c.keyword = ?)`), + `(${where.sql})`, + ].join(' AND '); + let head: string; + const params: SqlValue[] = []; + if (rare) { + head = box + ? 'email_keyword r INDEXED BY email_keyword_lookup CROSS JOIN email_mailbox d ON d.email_rowid = r.email_rowid AND d.mailbox_id = ?' + : 'email_keyword r INDEXED BY email_keyword_lookup'; + if (box) params.push(box); + } else { + head = box ? 'email_mailbox d INDEXED BY email_mailbox_list' : ''; + } + const emailJoin = !box + ? (rare ? ' CROSS JOIN email e ON e.rowid = r.email_rowid' : 'email e') + : ' CROSS JOIN email e ON e.rowid = d.email_rowid'; + const after: SqlValue[] = [jmap, ...(box && !rare ? [box] : []), ...checked.map((level) => level.keyword), ...where.params]; + const rareCondition = rare ? 'r.keyword = ? AND ' : ''; + const allParams = [...params, ...(rare ? [rare.keyword] : []), ...after]; + return { + // The message's own row is read only for what goes out, or if a condition needs it. + countFrom: box && !/\be\./.test(where.sql) ? head : `${head}${emailJoin}`, + from: `${head}${emailJoin}`, + where: `${rareCondition}${sql}`, + params: allParams, + order: box ? 'd.received_at DESC, d.email_rowid DESC' : 'e.received_at DESC, e.rowid DESC', + }; + }; + + const groups = levels.reduce>>( + (all, level) => all.flatMap((group) => [ + [...group, { keyword: level.keyword, has: level.first }], + [...group, { keyword: level.keyword, has: !level.first }], + ]), + [[]], + ); + + const ids: string[] = []; + let skip = position; + let need = limit; + for (const group of groups) { + if (need === 0) break; + const query = groupQuery(group); + if (skip > 0) { + const inGroup = Number(ctx.db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM ${query.countFrom} WHERE ${query.where}`, query.params)?.n ?? 0); + if (inGroup <= skip) { + skip -= inGroup; + continue; + } + } + const found = ctx.db.all<{ id: string }>( + `SELECT e.id FROM ${query.from} WHERE ${query.where} ORDER BY ${query.order} LIMIT ? OFFSET ?`, + [...query.params, need, skip], + ); + for (const row of found) ids.push(String(row.id)); + skip = 0; + if (need > 0) need -= found.length; + } + return ids; +} + const emailQuery: MethodHandler = (ctx, args) => { const jmap = jmapOf(ctx, args); const filter = (args.filter ?? undefined) as Filter | undefined; @@ -182,7 +301,11 @@ const emailQuery: MethodHandler = (ctx, args) => { const box = drivingMailbox(filter); const where = filter ? filterSql(filter, jmap, box) : { sql: '1', params: [] }; const sort = (args.sort as Comparator[]) ?? []; - const order: Sql = !isNewestFirst(sort) + const collapse = Boolean(args.collapseThreads); + const levels = collapse ? null : keywordLevels(ctx, sort); + const order: Sql = levels + ? { sql: '', params: [] } + : !isNewestFirst(sort) ? orderSql(ctx, sort) : { sql: box ? 'd.received_at DESC, d.email_rowid DESC' : 'e.received_at DESC, e.rowid DESC', params: [] }; // Starting from the folder's own index keeps small folders fast in a large account. @@ -194,12 +317,13 @@ const emailQuery: MethodHandler = (ctx, args) => { : { sql: 'e.jmap = ?', params: [jmap] as SqlValue[] }; const position = Math.max(0, Number(args.position ?? 0)); const limit = typeof args.limit === 'number' && args.limit > 0 ? args.limit : -1; - const collapse = Boolean(args.collapseThreads); const group = collapse ? 'GROUP BY COALESCE(e.thread_id, e.id)' : ''; - const ids = ctx.db.all<{ id: string }>( - `SELECT e.id FROM ${from} WHERE ${base.sql} AND (${where.sql}) ${group} ORDER BY ${order.sql} LIMIT ? OFFSET ?`, - [...base.params, ...where.params, ...order.params, limit, position], - ).map((row) => String(row.id)); + const ids = levels + ? keywordOrderedPage(ctx, jmap, box, where, levels, position, limit) + : ctx.db.all<{ id: string }>( + `SELECT e.id FROM ${from} WHERE ${base.sql} AND (${where.sql}) ${group} ORDER BY ${order.sql} LIMIT ? OFFSET ?`, + [...base.params, ...where.params, ...order.params, limit, position], + ).map((row) => String(row.id)); // Stalwart's queryState is the Email state. Absent until the first sync, as allowed. const queryState = lastState(ctx, jmap, 'Email'); const result: MethodResult = { @@ -211,8 +335,9 @@ const emailQuery: MethodHandler = (ctx, args) => { }; if (args.calculateTotal) { const count = collapse ? 'COUNT(DISTINCT COALESCE(e.thread_id, e.id))' : 'COUNT(*)'; - // A plain folder counts from its index alone, without reading a message. - const countFrom = box && !collapse && where.sql === '1' ? 'email_mailbox d INDEXED BY email_mailbox_list' : from; + // In a folder, what needs nothing of the message itself counts off the + // folder's index and the keyword rows, without reading a message. + const countFrom = box && !collapse && !/\be\./.test(where.sql) ? 'email_mailbox d INDEXED BY email_mailbox_list' : from; result.total = Number(ctx.db.get<{ n: number }>( `SELECT ${count} AS n FROM ${countFrom} WHERE ${base.sql} AND (${where.sql})`, [...base.params, ...where.params], diff --git a/lib/store/storage.ts b/lib/store/storage.ts index 5ca4c29af..d59a1b4c6 100644 --- a/lib/store/storage.ts +++ b/lib/store/storage.ts @@ -52,6 +52,16 @@ export function runTransaction( } } +/** + * The disk is full, the browser's quota ran out, the file can't be written. + * Trying again right away can't help, and each try costs network and power. + */ +export function isStorageFailure(error: unknown): boolean { + if (!(error instanceof Error)) return false; + if (error.name === 'QuotaExceededError') return true; + return /SQLITE_FULL|SQLITE_IOERR|SQLITE_CANTOPEN|disk is full|disk I\/O error|QuotaExceeded|No space left/i.test(error.message); +} + export interface Migration { /** Unique, ascending. Ranges per area are listed in lib/store/area.ts. */ id: number; diff --git a/locales/ar/common.json b/locales/ar/common.json index f04e8bfd3..d902c5f72 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5728,7 +5728,8 @@ "dismiss": "إغلاق", "dismiss_label": "إغلاق الملاحظة حول {subject}", "banner": "تغييرات لم تُطبَّق: {count}", - "toast": "تغييرات أُجريت دون اتصال ولم تُطبَّق: {count}. يوضح صندوق الصادر أيها." + "toast": "تغييرات أُجريت دون اتصال ولم تُطبَّق: {count}. يوضح صندوق الصادر أيها.", + "storage_failed": "لم تعد هناك مساحة على هذا الجهاز للبريد دون اتصال. أفرغ مساحة أو احتفظ بأقل من الإعدادات." }, "deep_link": { "message_not_found": "لم تعد هذه الرسالة متاحة.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 44533ccd5..c27bef7c1 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Descarta", "dismiss_label": "Descarta la nota sobre {subject}", "banner": "Canvis no aplicats: {count}", - "toast": "Canvis fets sense connexió que no s'han aplicat: {count}. La safata de sortida indica quins." + "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ó." }, "deep_link": { "message_not_found": "Aquest missatge ja no està disponible.", diff --git a/locales/cs/common.json b/locales/cs/common.json index 2d38069ba..fbc6190ad 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Zavřít", "dismiss_label": "Zavřít poznámku o {subject}", "banner": "Nepoužité změny: {count}", - "toast": "Nepoužité změny provedené offline: {count}. Které to jsou, ukazuje pošta k odeslání." + "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ě." }, "deep_link": { "message_not_found": "Tato zpráva už není dostupná.", diff --git a/locales/da/common.json b/locales/da/common.json index af860c968..b2aba243e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Afvis", "dismiss_label": "Afvis noten om {subject}", "banner": "Ændringer, der ikke blev gennemført: {count}", - "toast": "Ændringer lavet offline, der ikke blev gennemført: {count}. Udbakken viser hvilke." + "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." }, "deep_link": { "message_not_found": "Denne besked er ikke længere tilgængelig.", diff --git a/locales/de/common.json b/locales/de/common.json index 2acd1d0ca..b4816bd33 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Ausblenden", "dismiss_label": "Hinweis zu {subject} ausblenden", "banner": "Nicht übernommene Änderungen: {count}", - "toast": "Offline gemachte Änderungen wurden nicht übernommen: {count}. Welche, steht im Postausgang." + "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." }, "deep_link": { "message_not_found": "Diese Nachricht ist nicht mehr verfügbar.", diff --git a/locales/en/common.json b/locales/en/common.json index 05200844a..854565eb4 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Dismiss", "dismiss_label": "Dismiss the note about {subject}", "banner": "Changes that didn't go through: {count}", - "toast": "Changes you made offline weren't applied: {count}. The outbox shows which." + "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." }, "deep_link": { "message_not_found": "This message is no longer available.", diff --git a/locales/es/common.json b/locales/es/common.json index d62a7cf91..71dfb63c2 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Descartar", "dismiss_label": "Descartar la nota sobre {subject}", "banner": "Cambios no aplicados: {count}", - "toast": "Cambios hechos sin conexión que no se aplicaron: {count}. La bandeja de salida indica cuáles." + "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." }, "deep_link": { "message_not_found": "Este mensaje ya no está disponible.", diff --git a/locales/et/common.json b/locales/et/common.json index a14bf0d2e..4dd87b1df 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Sulge", "dismiss_label": "Sulge märkus teemal {subject}", "banner": "Rakendamata muudatused: {count}", - "toast": "Võrguühenduseta tehtud rakendamata muudatused: {count}. Väljundkaust näitab, millised." + "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." }, "deep_link": { "message_not_found": "See sõnum ei ole enam saadaval.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 226d439f5..631f343fa 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5728,7 +5728,8 @@ "dismiss": "بستن", "dismiss_label": "بستن یادداشت درباره {subject}", "banner": "تغییراتی که اعمال نشدند: {count}", - "toast": "تغییرات آفلاین که اعمال نشدند: {count}. صندوق خروجی نشان می‌دهد کدام‌ها." + "toast": "تغییرات آفلاین که اعمال نشدند: {count}. صندوق خروجی نشان می‌دهد کدام‌ها.", + "storage_failed": "در این دستگاه جایی برای ایمیل آفلاین نمانده است. فضا را آزاد کنید یا در تنظیمات مقدار کمتری نگه دارید." }, "deep_link": { "message_not_found": "این پیام دیگر در دسترس نیست.", diff --git a/locales/fr/common.json b/locales/fr/common.json index f014f7d27..39f105d91 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Ignorer", "dismiss_label": "Ignorer la note sur {subject}", "banner": "Modifications non appliquées : {count}", - "toast": "Des modifications faites hors ligne n'ont pas été appliquées : {count}. La boîte d'envoi indique lesquelles." + "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." }, "deep_link": { "message_not_found": "Ce message n'est plus disponible.", diff --git a/locales/he/common.json b/locales/he/common.json index 38d7bbfe9..c908f32e3 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5729,7 +5729,8 @@ "dismiss": "סגירה", "dismiss_label": "סגירת ההערה על {subject}", "banner": "שינויים שלא הוחלו: {count}", - "toast": "שינויים במצב לא מקוון שלא הוחלו: {count}. דואר יוצא מראה אילו." + "toast": "שינויים במצב לא מקוון שלא הוחלו: {count}. דואר יוצא מראה אילו.", + "storage_failed": "אין יותר מקום במכשיר הזה לדואר לא מקוון. יש לפנות מקום או לשמור פחות בהגדרות." }, "deep_link": { "message_not_found": "הודעה זו אינה זמינה עוד.", diff --git a/locales/hu/common.json b/locales/hu/common.json index c973f3228..88aaa37ff 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Bezárás", "dismiss_label": "A(z) {subject} megjegyzésének bezárása", "banner": "Nem alkalmazott módosítások: {count}", - "toast": "Offline végzett, nem alkalmazott módosítások: {count}. A postázandók mappa mutatja, melyek." + "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." }, "deep_link": { "message_not_found": "Ez az üzenet már nem érhető el.", diff --git a/locales/it/common.json b/locales/it/common.json index 74ad78625..518c392b8 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Ignora", "dismiss_label": "Ignora la nota su {subject}", "banner": "Modifiche non applicate: {count}", - "toast": "Modifiche fatte offline non applicate: {count}. La posta in uscita indica quali." + "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." }, "deep_link": { "message_not_found": "Questo messaggio non è più disponibile.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 57c75a70b..bf8e1052d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5728,7 +5728,8 @@ "dismiss": "閉じる", "dismiss_label": "{subject} のメモを閉じる", "banner": "適用されなかった変更: {count}", - "toast": "オフライン中の変更で適用されなかったもの: {count} 件。送信トレイで確認できます。" + "toast": "オフライン中の変更で適用されなかったもの: {count} 件。送信トレイで確認できます。", + "storage_failed": "このデバイスにはオフラインメール用の空き容量がありません。空き容量を増やすか、設定で保存する量を減らしてください。" }, "deep_link": { "message_not_found": "このメッセージは利用できなくなりました。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 52a8124db..d4f951a5f 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5728,7 +5728,8 @@ "dismiss": "닫기", "dismiss_label": "{subject} 관련 메모 닫기", "banner": "적용되지 않은 변경 사항: {count}", - "toast": "오프라인에서 변경했지만 적용되지 않은 항목: {count}개. 보낼 편지함에서 확인할 수 있습니다." + "toast": "오프라인에서 변경했지만 적용되지 않은 항목: {count}개. 보낼 편지함에서 확인할 수 있습니다.", + "storage_failed": "이 기기에 오프라인 메일을 저장할 공간이 없습니다. 공간을 확보하거나 설정에서 보관량을 줄이세요." }, "deep_link": { "message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index b69fb71f6..05fcb26c4 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Aizvērt", "dismiss_label": "Aizvērt piezīmi par {subject}", "banner": "Nepiemērotās izmaiņas: {count}", - "toast": "Bezsaistē veiktās nepiemērotās izmaiņas: {count}. Izsūtne parāda, kuras." + "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." }, "deep_link": { "message_not_found": "Šī ziņa vairs nav pieejama.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 5a44c9fe4..760bc1a03 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Хаах", "dismiss_label": "{subject}-ийн тэмдэглэлийг хаах", "banner": "Хэрэгжээгүй өөрчлөлтүүд: {count}", - "toast": "Офлайн хийсэн, хэрэгжээгүй өөрчлөлтүүд: {count}. Аль нь болохыг Илгээх хайрцгаас харна уу." + "toast": "Офлайн хийсэн, хэрэгжээгүй өөрчлөлтүүд: {count}. Аль нь болохыг Илгээх хайрцгаас харна уу.", + "storage_failed": "Энэ төхөөрөмжид офлайн захидалд зай үлдсэнгүй. Зай чөлөөлөх эсвэл тохиргооноос цөөнийг хадгална уу." }, "deep_link": { "message_not_found": "Энэ зурвас дахин ашиглах боломжгүй болсон.", diff --git a/locales/nb/common.json b/locales/nb/common.json index e9d7e3456..619d79e9f 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Lukk", "dismiss_label": "Lukk merknaden om {subject}", "banner": "Endringer som ikke ble utført: {count}", - "toast": "Endringer gjort uten nett som ikke ble utført: {count}. Utboksen viser hvilke." + "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." }, "deep_link": { "message_not_found": "Denne meldingen er ikke lenger tilgjengelig.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 288c2b5d5..55829f868 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Sluiten", "dismiss_label": "Melding over {subject} sluiten", "banner": "Niet doorgevoerde wijzigingen: {count}", - "toast": "Offline gemaakte wijzigingen niet doorgevoerd: {count}. Het Postvak UIT laat zien welke." + "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." }, "deep_link": { "message_not_found": "Dit bericht is niet meer beschikbaar.", diff --git a/locales/pl/common.json b/locales/pl/common.json index a57a4d941..441641f50 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Odrzuć", "dismiss_label": "Odrzuć uwagę o {subject}", "banner": "Niezastosowane zmiany: {count}", - "toast": "Niezastosowane zmiany wprowadzone offline: {count}. Skrzynka nadawcza pokazuje które." + "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." }, "deep_link": { "message_not_found": "Ta wiadomość nie jest już dostępna.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 06cfcb46e..eb5f1ecb7 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Dispensar", "dismiss_label": "Dispensar a nota sobre {subject}", "banner": "Alterações não aplicadas: {count}", - "toast": "Alterações feitas offline não aplicadas: {count}. A caixa de saída mostra quais." + "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." }, "deep_link": { "message_not_found": "Esta mensagem não está mais disponível.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 442bd61a4..65ce649b0 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Închide", "dismiss_label": "Închide nota despre {subject}", "banner": "Modificări neaplicate: {count}", - "toast": "Modificări făcute offline care nu au fost aplicate: {count}. Căsuța de trimitere arată care." + "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." }, "deep_link": { "message_not_found": "Acest mesaj nu mai este disponibil.", diff --git a/locales/ru/common.json b/locales/ru/common.json index bf67602d1..858f407b7 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Закрыть", "dismiss_label": "Закрыть заметку о {subject}", "banner": "Неприменённые изменения: {count}", - "toast": "Изменения, внесённые офлайн и не применённые: {count}. Какие именно, видно в исходящих." + "toast": "Изменения, внесённые офлайн и не применённые: {count}. Какие именно, видно в исходящих.", + "storage_failed": "На этом устройстве не осталось места для офлайн-почты. Освободите место или храните меньше в настройках." }, "deep_link": { "message_not_found": "Это письмо больше недоступно.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 570b68af9..8f172e8a7 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Zavrieť", "dismiss_label": "Zavrieť poznámku o {subject}", "banner": "Nepoužité zmeny: {count}", - "toast": "Nepoužité zmeny urobené offline: {count}. Ktoré to sú, ukazuje pošta na odoslanie." + "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." }, "deep_link": { "message_not_found": "Táto správa už nie je dostupná.", diff --git a/locales/th/common.json b/locales/th/common.json index 6956a8c12..1b026d356 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2098,7 +2098,8 @@ "dismiss": "ปิด", "dismiss_label": "ปิดหมายเหตุเกี่ยวกับ {subject}", "banner": "การเปลี่ยนแปลงที่ไม่ได้นำไปใช้: {count}", - "toast": "การเปลี่ยนแปลงขณะออฟไลน์ที่ไม่ได้นำไปใช้: {count} ดูรายการได้ในกล่องขาออก" + "toast": "การเปลี่ยนแปลงขณะออฟไลน์ที่ไม่ได้นำไปใช้: {count} ดูรายการได้ในกล่องขาออก", + "storage_failed": "อุปกรณ์นี้ไม่มีที่ว่างสำหรับอีเมลออฟไลน์แล้ว โปรดเพิ่มพื้นที่ว่างหรือเก็บให้น้อยลงในการตั้งค่า" }, "plugin_pages": { "loading": "กำลังโหลดหน้าเว็บ...", diff --git a/locales/tr/common.json b/locales/tr/common.json index ed5241d9f..4fd4bb488 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Kapat", "dismiss_label": "{subject} ile ilgili notu kapat", "banner": "Uygulanmayan değişiklikler: {count}", - "toast": "Çevrimdışı yapılan ve uygulanmayan değişiklikler: {count}. Giden kutusu hangileri olduğunu gösterir." + "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." }, "deep_link": { "message_not_found": "Bu ileti artık kullanılamıyor.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 9ca4f5483..9cba0da86 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5728,7 +5728,8 @@ "dismiss": "Закрити", "dismiss_label": "Закрити примітку про {subject}", "banner": "Незастосовані зміни: {count}", - "toast": "Зміни, внесені офлайн і не застосовані: {count}. Які саме, видно у вихідних." + "toast": "Зміни, внесені офлайн і не застосовані: {count}. Які саме, видно у вихідних.", + "storage_failed": "На цьому пристрої не залишилося місця для офлайн-пошти. Звільніть місце або зберігайте менше в налаштуваннях." }, "deep_link": { "message_not_found": "Цей лист більше недоступний.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 6e42f7de8..24d1f4cf7 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5740,7 +5740,8 @@ "dismiss": "Đóng", "dismiss_label": "Đóng ghi chú về {subject}", "banner": "Các thay đổi chưa được áp dụng: {count}", - "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." + "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." }, "deep_link": { "message_not_found": "Thư này không còn nữa.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index d767f1990..1ee613765 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5728,7 +5728,8 @@ "dismiss": "關閉", "dismiss_label": "關閉關於 {subject} 的提示", "banner": "未套用的變更:{count}", - "toast": "離線時的變更未被套用:{count} 項。可在寄件匣中查看。" + "toast": "離線時的變更未被套用:{count} 項。可在寄件匣中查看。", + "storage_failed": "此裝置已沒有空間存放離線郵件。請釋出空間,或在設定中減少保留的內容。" }, "deep_link": { "message_not_found": "已無法使用此郵件。", diff --git a/locales/zh/common.json b/locales/zh/common.json index cc572a378..78bce507b 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5728,7 +5728,8 @@ "dismiss": "关闭", "dismiss_label": "关闭关于 {subject} 的提示", "banner": "未应用的更改:{count}", - "toast": "离线时的更改未被应用:{count} 项。可在发件箱中查看。" + "toast": "离线时的更改未被应用:{count} 项。可在发件箱中查看。", + "storage_failed": "此设备已没有空间存放离线邮件。请释放空间,或在设置中减少保留的内容。" }, "deep_link": { "message_not_found": "此邮件已不可用。",