mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
fix: keep pinned and unread-first lists off a full sort, and stop syncing when the disk is full
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5728,7 +5728,8 @@
|
||||
"dismiss": "إغلاق",
|
||||
"dismiss_label": "إغلاق الملاحظة حول {subject}",
|
||||
"banner": "تغييرات لم تُطبَّق: {count}",
|
||||
"toast": "تغييرات أُجريت دون اتصال ولم تُطبَّق: {count}. يوضح صندوق الصادر أيها."
|
||||
"toast": "تغييرات أُجريت دون اتصال ولم تُطبَّق: {count}. يوضح صندوق الصادر أيها.",
|
||||
"storage_failed": "لم تعد هناك مساحة على هذا الجهاز للبريد دون اتصال. أفرغ مساحة أو احتفظ بأقل من الإعدادات."
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "لم تعد هذه الرسالة متاحة.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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á.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -5728,7 +5728,8 @@
|
||||
"dismiss": "بستن",
|
||||
"dismiss_label": "بستن یادداشت درباره {subject}",
|
||||
"banner": "تغییراتی که اعمال نشدند: {count}",
|
||||
"toast": "تغییرات آفلاین که اعمال نشدند: {count}. صندوق خروجی نشان میدهد کدامها."
|
||||
"toast": "تغییرات آفلاین که اعمال نشدند: {count}. صندوق خروجی نشان میدهد کدامها.",
|
||||
"storage_failed": "در این دستگاه جایی برای ایمیل آفلاین نمانده است. فضا را آزاد کنید یا در تنظیمات مقدار کمتری نگه دارید."
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "این پیام دیگر در دسترس نیست.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -5729,7 +5729,8 @@
|
||||
"dismiss": "סגירה",
|
||||
"dismiss_label": "סגירת ההערה על {subject}",
|
||||
"banner": "שינויים שלא הוחלו: {count}",
|
||||
"toast": "שינויים במצב לא מקוון שלא הוחלו: {count}. דואר יוצא מראה אילו."
|
||||
"toast": "שינויים במצב לא מקוון שלא הוחלו: {count}. דואר יוצא מראה אילו.",
|
||||
"storage_failed": "אין יותר מקום במכשיר הזה לדואר לא מקוון. יש לפנות מקום או לשמור פחות בהגדרות."
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "הודעה זו אינה זמינה עוד.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -5728,7 +5728,8 @@
|
||||
"dismiss": "閉じる",
|
||||
"dismiss_label": "{subject} のメモを閉じる",
|
||||
"banner": "適用されなかった変更: {count}",
|
||||
"toast": "オフライン中の変更で適用されなかったもの: {count} 件。送信トレイで確認できます。"
|
||||
"toast": "オフライン中の変更で適用されなかったもの: {count} 件。送信トレイで確認できます。",
|
||||
"storage_failed": "このデバイスにはオフラインメール用の空き容量がありません。空き容量を増やすか、設定で保存する量を減らしてください。"
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "このメッセージは利用できなくなりました。",
|
||||
|
||||
@@ -5728,7 +5728,8 @@
|
||||
"dismiss": "닫기",
|
||||
"dismiss_label": "{subject} 관련 메모 닫기",
|
||||
"banner": "적용되지 않은 변경 사항: {count}",
|
||||
"toast": "오프라인에서 변경했지만 적용되지 않은 항목: {count}개. 보낼 편지함에서 확인할 수 있습니다."
|
||||
"toast": "오프라인에서 변경했지만 적용되지 않은 항목: {count}개. 보낼 편지함에서 확인할 수 있습니다.",
|
||||
"storage_failed": "이 기기에 오프라인 메일을 저장할 공간이 없습니다. 공간을 확보하거나 설정에서 보관량을 줄이세요."
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -5728,7 +5728,8 @@
|
||||
"dismiss": "Хаах",
|
||||
"dismiss_label": "{subject}-ийн тэмдэглэлийг хаах",
|
||||
"banner": "Хэрэгжээгүй өөрчлөлтүүд: {count}",
|
||||
"toast": "Офлайн хийсэн, хэрэгжээгүй өөрчлөлтүүд: {count}. Аль нь болохыг Илгээх хайрцгаас харна уу."
|
||||
"toast": "Офлайн хийсэн, хэрэгжээгүй өөрчлөлтүүд: {count}. Аль нь болохыг Илгээх хайрцгаас харна уу.",
|
||||
"storage_failed": "Энэ төхөөрөмжид офлайн захидалд зай үлдсэнгүй. Зай чөлөөлөх эсвэл тохиргооноос цөөнийг хадгална уу."
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Энэ зурвас дахин ашиглах боломжгүй болсон.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -5728,7 +5728,8 @@
|
||||
"dismiss": "Закрыть",
|
||||
"dismiss_label": "Закрыть заметку о {subject}",
|
||||
"banner": "Неприменённые изменения: {count}",
|
||||
"toast": "Изменения, внесённые офлайн и не применённые: {count}. Какие именно, видно в исходящих."
|
||||
"toast": "Изменения, внесённые офлайн и не применённые: {count}. Какие именно, видно в исходящих.",
|
||||
"storage_failed": "На этом устройстве не осталось места для офлайн-почты. Освободите место или храните меньше в настройках."
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Это письмо больше недоступно.",
|
||||
|
||||
@@ -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á.",
|
||||
|
||||
@@ -2098,7 +2098,8 @@
|
||||
"dismiss": "ปิด",
|
||||
"dismiss_label": "ปิดหมายเหตุเกี่ยวกับ {subject}",
|
||||
"banner": "การเปลี่ยนแปลงที่ไม่ได้นำไปใช้: {count}",
|
||||
"toast": "การเปลี่ยนแปลงขณะออฟไลน์ที่ไม่ได้นำไปใช้: {count} ดูรายการได้ในกล่องขาออก"
|
||||
"toast": "การเปลี่ยนแปลงขณะออฟไลน์ที่ไม่ได้นำไปใช้: {count} ดูรายการได้ในกล่องขาออก",
|
||||
"storage_failed": "อุปกรณ์นี้ไม่มีที่ว่างสำหรับอีเมลออฟไลน์แล้ว โปรดเพิ่มพื้นที่ว่างหรือเก็บให้น้อยลงในการตั้งค่า"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "กำลังโหลดหน้าเว็บ...",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -5728,7 +5728,8 @@
|
||||
"dismiss": "Закрити",
|
||||
"dismiss_label": "Закрити примітку про {subject}",
|
||||
"banner": "Незастосовані зміни: {count}",
|
||||
"toast": "Зміни, внесені офлайн і не застосовані: {count}. Які саме, видно у вихідних."
|
||||
"toast": "Зміни, внесені офлайн і не застосовані: {count}. Які саме, видно у вихідних.",
|
||||
"storage_failed": "На цьому пристрої не залишилося місця для офлайн-пошти. Звільніть місце або зберігайте менше в налаштуваннях."
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Цей лист більше недоступний.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -5728,7 +5728,8 @@
|
||||
"dismiss": "關閉",
|
||||
"dismiss_label": "關閉關於 {subject} 的提示",
|
||||
"banner": "未套用的變更:{count}",
|
||||
"toast": "離線時的變更未被套用:{count} 項。可在寄件匣中查看。"
|
||||
"toast": "離線時的變更未被套用:{count} 項。可在寄件匣中查看。",
|
||||
"storage_failed": "此裝置已沒有空間存放離線郵件。請釋出空間,或在設定中減少保留的內容。"
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "已無法使用此郵件。",
|
||||
|
||||
@@ -5728,7 +5728,8 @@
|
||||
"dismiss": "关闭",
|
||||
"dismiss_label": "关闭关于 {subject} 的提示",
|
||||
"banner": "未应用的更改:{count}",
|
||||
"toast": "离线时的更改未被应用:{count} 项。可在发件箱中查看。"
|
||||
"toast": "离线时的更改未被应用:{count} 项。可在发件箱中查看。",
|
||||
"storage_failed": "此设备已没有空间存放离线邮件。请释放空间,或在设置中减少保留的内容。"
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "此邮件已不可用。",
|
||||
|
||||
Reference in New Issue
Block a user