fix: keep pinned and unread-first lists off a full sort, and stop syncing when the disk is full

This commit is contained in:
Linus Rath
2026-11-03 06:02:10 +01:00
parent e38b023ee3
commit f6db7ea53a
37 changed files with 556 additions and 61 deletions
+3
View File
@@ -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;
+12
View File
@@ -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' });
}
},
};
+276
View File
@@ -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: <T extends object>(sql: string, params?: readonly SqlValue[]) => {
seen.push({ sql, params: params ?? [] });
return db.all<T>(sql, params);
},
get: <T extends object>(sql: string, params?: readonly SqlValue[]) => {
seen.push({ sql, params: params ?? [] });
return db.get<T>(sql, params);
},
transaction: (fn) => db.transaction(fn),
close: () => db.close(),
};
return { spied, seen };
}
function setup(emails: Array<{ id: string; keywords?: Record<string, boolean>; mailboxIds?: Record<string, boolean> }>) {
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<string, unknown>): Promise<string[]> {
const answer = await engine.answer([['Email/query', args, 'q']]);
return answer?.methodResponses[0][1].ids;
}
async function plans(t: ReturnType<typeof setup>, args: Record<string, unknown>) {
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<string, boolean>; mailboxIds?: Record<string, boolean> }> = [
{ 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<typeof setup>;
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);
});
});
+3
View File
@@ -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. */
+50 -14
View File
@@ -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<void> | 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<void> {
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<void> {
@@ -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<unknown> {
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<JmapResponse | null> {
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. */
+142 -17
View File
@@ -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<string, number>();
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<Array<Array<{ keyword: string; has: boolean }>>>(
(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],
+10
View File
@@ -52,6 +52,16 @@ export function runTransaction<T>(
}
}
/**
* 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;
+2 -1
View File
@@ -5728,7 +5728,8 @@
"dismiss": "إغلاق",
"dismiss_label": "إغلاق الملاحظة حول {subject}",
"banner": "تغييرات لم تُطبَّق: {count}",
"toast": "تغييرات أُجريت دون اتصال ولم تُطبَّق: {count}. يوضح صندوق الصادر أيها."
"toast": "تغييرات أُجريت دون اتصال ولم تُطبَّق: {count}. يوضح صندوق الصادر أيها.",
"storage_failed": "لم تعد هناك مساحة على هذا الجهاز للبريد دون اتصال. أفرغ مساحة أو احتفظ بأقل من الإعدادات."
},
"deep_link": {
"message_not_found": "لم تعد هذه الرسالة متاحة.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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á.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -5728,7 +5728,8 @@
"dismiss": "بستن",
"dismiss_label": "بستن یادداشت درباره {subject}",
"banner": "تغییراتی که اعمال نشدند: {count}",
"toast": "تغییرات آفلاین که اعمال نشدند: {count}. صندوق خروجی نشان می‌دهد کدام‌ها."
"toast": "تغییرات آفلاین که اعمال نشدند: {count}. صندوق خروجی نشان می‌دهد کدام‌ها.",
"storage_failed": "در این دستگاه جایی برای ایمیل آفلاین نمانده است. فضا را آزاد کنید یا در تنظیمات مقدار کمتری نگه دارید."
},
"deep_link": {
"message_not_found": "این پیام دیگر در دسترس نیست.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -5729,7 +5729,8 @@
"dismiss": "סגירה",
"dismiss_label": "סגירת ההערה על {subject}",
"banner": "שינויים שלא הוחלו: {count}",
"toast": "שינויים במצב לא מקוון שלא הוחלו: {count}. דואר יוצא מראה אילו."
"toast": "שינויים במצב לא מקוון שלא הוחלו: {count}. דואר יוצא מראה אילו.",
"storage_failed": "אין יותר מקום במכשיר הזה לדואר לא מקוון. יש לפנות מקום או לשמור פחות בהגדרות."
},
"deep_link": {
"message_not_found": "הודעה זו אינה זמינה עוד.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -5728,7 +5728,8 @@
"dismiss": "閉じる",
"dismiss_label": "{subject} のメモを閉じる",
"banner": "適用されなかった変更: {count}",
"toast": "オフライン中の変更で適用されなかったもの: {count} 件。送信トレイで確認できます。"
"toast": "オフライン中の変更で適用されなかったもの: {count} 件。送信トレイで確認できます。",
"storage_failed": "このデバイスにはオフラインメール用の空き容量がありません。空き容量を増やすか、設定で保存する量を減らしてください。"
},
"deep_link": {
"message_not_found": "このメッセージは利用できなくなりました。",
+2 -1
View File
@@ -5728,7 +5728,8 @@
"dismiss": "닫기",
"dismiss_label": "{subject} 관련 메모 닫기",
"banner": "적용되지 않은 변경 사항: {count}",
"toast": "오프라인에서 변경했지만 적용되지 않은 항목: {count}개. 보낼 편지함에서 확인할 수 있습니다."
"toast": "오프라인에서 변경했지만 적용되지 않은 항목: {count}개. 보낼 편지함에서 확인할 수 있습니다.",
"storage_failed": "이 기기에 오프라인 메일을 저장할 공간이 없습니다. 공간을 확보하거나 설정에서 보관량을 줄이세요."
},
"deep_link": {
"message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -5728,7 +5728,8 @@
"dismiss": "Хаах",
"dismiss_label": "{subject}-ийн тэмдэглэлийг хаах",
"banner": "Хэрэгжээгүй өөрчлөлтүүд: {count}",
"toast": "Офлайн хийсэн, хэрэгжээгүй өөрчлөлтүүд: {count}. Аль нь болохыг Илгээх хайрцгаас харна уу."
"toast": "Офлайн хийсэн, хэрэгжээгүй өөрчлөлтүүд: {count}. Аль нь болохыг Илгээх хайрцгаас харна уу.",
"storage_failed": "Энэ төхөөрөмжид офлайн захидалд зай үлдсэнгүй. Зай чөлөөлөх эсвэл тохиргооноос цөөнийг хадгална уу."
},
"deep_link": {
"message_not_found": "Энэ зурвас дахин ашиглах боломжгүй болсон.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -5728,7 +5728,8 @@
"dismiss": "Закрыть",
"dismiss_label": "Закрыть заметку о {subject}",
"banner": "Неприменённые изменения: {count}",
"toast": "Изменения, внесённые офлайн и не применённые: {count}. Какие именно, видно в исходящих."
"toast": "Изменения, внесённые офлайн и не применённые: {count}. Какие именно, видно в исходящих.",
"storage_failed": "На этом устройстве не осталось места для офлайн-почты. Освободите место или храните меньше в настройках."
},
"deep_link": {
"message_not_found": "Это письмо больше недоступно.",
+2 -1
View File
@@ -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á.",
+2 -1
View File
@@ -2098,7 +2098,8 @@
"dismiss": "ปิด",
"dismiss_label": "ปิดหมายเหตุเกี่ยวกับ {subject}",
"banner": "การเปลี่ยนแปลงที่ไม่ได้นำไปใช้: {count}",
"toast": "การเปลี่ยนแปลงขณะออฟไลน์ที่ไม่ได้นำไปใช้: {count} ดูรายการได้ในกล่องขาออก"
"toast": "การเปลี่ยนแปลงขณะออฟไลน์ที่ไม่ได้นำไปใช้: {count} ดูรายการได้ในกล่องขาออก",
"storage_failed": "อุปกรณ์นี้ไม่มีที่ว่างสำหรับอีเมลออฟไลน์แล้ว โปรดเพิ่มพื้นที่ว่างหรือเก็บให้น้อยลงในการตั้งค่า"
},
"plugin_pages": {
"loading": "กำลังโหลดหน้าเว็บ...",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -5728,7 +5728,8 @@
"dismiss": "Закрити",
"dismiss_label": "Закрити примітку про {subject}",
"banner": "Незастосовані зміни: {count}",
"toast": "Зміни, внесені офлайн і не застосовані: {count}. Які саме, видно у вихідних."
"toast": "Зміни, внесені офлайн і не застосовані: {count}. Які саме, видно у вихідних.",
"storage_failed": "На цьому пристрої не залишилося місця для офлайн-пошти. Звільніть місце або зберігайте менше в налаштуваннях."
},
"deep_link": {
"message_not_found": "Цей лист більше недоступний.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -5728,7 +5728,8 @@
"dismiss": "關閉",
"dismiss_label": "關閉關於 {subject} 的提示",
"banner": "未套用的變更:{count}",
"toast": "離線時的變更未被套用:{count} 項。可在寄件匣中查看。"
"toast": "離線時的變更未被套用:{count} 項。可在寄件匣中查看。",
"storage_failed": "此裝置已沒有空間存放離線郵件。請釋出空間,或在設定中減少保留的內容。"
},
"deep_link": {
"message_not_found": "已無法使用此郵件。",
+2 -1
View File
@@ -5728,7 +5728,8 @@
"dismiss": "关闭",
"dismiss_label": "关闭关于 {subject} 的提示",
"banner": "未应用的更改:{count}",
"toast": "离线时的更改未被应用:{count} 项。可在发件箱中查看。"
"toast": "离线时的更改未被应用:{count} 项。可在发件箱中查看。",
"storage_failed": "此设备已没有空间存放离线邮件。请释放空间,或在设置中减少保留的内容。"
},
"deep_link": {
"message_not_found": "此邮件已不可用。",