From 4b60fcc1e6cabd5fdf6c3155e70eafd46da60bab Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:19:54 +0100 Subject: [PATCH] fix: make the offline outbox never send twice, never lose mail silently and never skip a plugin #523 The review of offline mode found ways to send a message twice, to lose unsent mail without a word, and to send mail without its signing, encryption or check plugin. This fixes them and the smaller findings: - each outbox message is taken for sending in one database transaction under a Web Lock per account; only a failure before the submission request (SendNotStartedError from the client) puts it back, any other one marks it "may have gone out"; read-backs after the submission no longer fail a send; a send of a closed tab turns uncertain after a minute - every sign-out the user didn't confirm names the deleted messages on the sign-in page, or in a toast when another account stays signed in - nothing is queued while an enabled plugin isn't running (after a start without the network none can); the composer says why - the outbox waits for a fresh policy and holds messages the rules have since made stricter (a required label, a required send check) - another tab switching offline mode off sticks: only switching it on may create the database - only attachments of kept messages are kept, filed with their message, deleted with it and under the cap between syncs; file names and types are sealed; folders that went away leave the scope, and a folder that couldn't be asked keeps its mail without holding the others back - a group scope that forbids offline mode waits until the account's groups are known; one server down no longer holds another's mail - an offline start only when the server is really unreachable, and the real sign-in follows by itself once it answers - the service worker files pages and chunks under the build that served them, and the shell list of another build isn't kept under this one - a raw send counts as the composer's only from the plugin running its takeover, for that identity - errors and refusals are in all languages; the banner tells "offline" from "server can't be reached"; outbox buttons name their message; changes and messages go in the order they were made --- .../login/__tests__/login-page-a11y.test.tsx | 8 + app/(main)/[locale]/login/page.tsx | 17 +- components/email/email-composer.tsx | 16 +- .../__tests__/offline-settings.test.tsx | 4 +- components/offline/offline-banner.tsx | 20 +- components/offline/offline-host.tsx | 17 +- components/offline/outbox-list.tsx | 13 +- lib/jmap/client.ts | 132 ++++-- lib/jmap/offline-hooks.ts | 12 + lib/jmap/types.ts | 2 + lib/offline/__tests__/cache.test.ts | 3 +- lib/offline/__tests__/client-fallback.test.ts | 9 +- lib/offline/__tests__/client-send.test.ts | 95 ++++ lib/offline/__tests__/outbox.test.ts | 14 +- lib/offline/__tests__/responder.test.ts | 3 +- lib/offline/__tests__/review-fixes.test.ts | 342 ++++++++++++++ lib/offline/cache.ts | 35 +- lib/offline/db.ts | 73 ++- lib/offline/flush.ts | 176 +++++-- lib/offline/lost.ts | 42 ++ lib/offline/offline-mode.ts | 438 +++++++++++++++--- lib/offline/queue.ts | 102 +++- lib/offline/responder.ts | 29 +- lib/offline/shell.ts | 15 +- lib/offline/state.ts | 41 +- lib/offline/sync.ts | 32 +- lib/outgoing/compose-takeover.ts | 35 +- lib/plugin-hooks.ts | 15 + lib/plugin-sandbox/host-api.ts | 10 +- lib/session-policy/sign-out-reason.ts | 27 +- lib/sign-out-cleanup.ts | 4 +- locales/ar/common.json | 19 +- locales/ca/common.json | 19 +- locales/cs/common.json | 19 +- locales/da/common.json | 19 +- locales/de/common.json | 23 +- locales/en/common.json | 19 +- locales/es/common.json | 19 +- locales/et/common.json | 19 +- locales/fa/common.json | 19 +- locales/fr/common.json | 19 +- locales/he/common.json | 19 +- locales/hu/common.json | 19 +- locales/it/common.json | 19 +- locales/ja/common.json | 19 +- locales/ko/common.json | 19 +- locales/lv/common.json | 19 +- locales/mn/common.json | 19 +- locales/nb/common.json | 19 +- locales/nl/common.json | 31 +- locales/pl/common.json | 19 +- locales/pt/common.json | 19 +- locales/ro/common.json | 31 +- locales/ru/common.json | 19 +- locales/sk/common.json | 19 +- locales/th/common.json | 19 +- locales/tr/common.json | 19 +- locales/uk/common.json | 19 +- locales/vi/common.json | 19 +- locales/zh-TW/common.json | 19 +- locales/zh/common.json | 19 +- next.config.ts | 7 +- public/sw.js | 14 +- .../__tests__/auth-store-signout-gate.test.ts | 52 +++ stores/auth-store.ts | 20 + stores/email-store.ts | 7 +- 66 files changed, 2098 insertions(+), 381 deletions(-) create mode 100644 lib/offline/__tests__/client-send.test.ts create mode 100644 lib/offline/__tests__/review-fixes.test.ts create mode 100644 lib/offline/lost.ts create mode 100644 stores/__tests__/auth-store-signout-gate.test.ts diff --git a/app/(main)/[locale]/login/__tests__/login-page-a11y.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-a11y.test.tsx index 2e0de0c8f..0e43d783d 100644 --- a/app/(main)/[locale]/login/__tests__/login-page-a11y.test.tsx +++ b/app/(main)/[locale]/login/__tests__/login-page-a11y.test.tsx @@ -135,4 +135,12 @@ describe('login form accessibility', () => { expect(screen.getByText('drafts_not_saved')).toBeInTheDocument(); expect(sessionStorage.getItem('session_expired_drafts_unsaved')).toBeNull(); }, 30_000); + + // Offline mode (#523): an automatic sign-out deleted unsent messages. + it('names the offline outbox messages a sign-out deleted, once', async () => { + sessionStorage.setItem('bulwark-offline-lost', JSON.stringify(['Quarterly numbers'])); + await renderForm(); + expect((await screen.findByTestId('session-outbox-lost')).textContent).toContain('signout.lost'); + expect(sessionStorage.getItem('bulwark-offline-lost')).toBeNull(); + }, 30_000); }); diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index e144984b8..efdf33a1d 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { IconButton } from "@/components/ui/icon-button"; import { Input } from "@/components/ui/input"; import { useAuthStore, consumeSignedOut, SESSION_EXPIRED_DRAFTS_UNSAVED_KEY } from "@/stores/auth-store"; +import { consumeLost } from "@/lib/offline/lost"; import { useAccountStore } from "@/stores/account-store"; import { useThemeStore } from "@/stores/theme-store"; import { usePolicyStore } from "@/stores/policy-store"; @@ -270,6 +271,11 @@ function LoginPageContent() { const [sessionExpired, setSessionExpired] = useState(false); // Signed out by the organisation's idle timeout or maximum session length. const [sessionEnd, setSessionEnd] = useState(null); + // Offline outbox messages a sign-out deleted unsent (#523), named once. + const [outboxLost, setOutboxLost] = useState([]); + useEffect(() => { + setOutboxLost(consumeLost()); + }, []); const [expiredDraftsUnsaved, setExpiredDraftsUnsaved] = useState(false); const [showPassword, setShowPassword] = useState(false); const [shakeError, setShakeError] = useState(false); @@ -1399,6 +1405,12 @@ function LoginPageContent() { {/* Form section */}
+ {outboxLost.length > 0 && ( +
+ {tOffline("signout.lost", { subjects: outboxLost.map((s) => s || tOffline("outbox.no_subject")).join(", ") })} +
+ )} + {/* Session Expired Banner */} {sessionExpired && (
{tSessionLimits("drafts_not_saved")} )} - {sessionEnd?.outboxLost?.length ? ( - - {tOffline("signout.lost", { subjects: sessionEnd.outboxLost.map((s) => s || tOffline("outbox.no_subject")).join(", ") })} - - ) : null}

+ )} {row.status !== 'sending' && ( - + )}
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index eee8b5062..cdec1af22 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -41,7 +41,7 @@ import { emailMatchesLogin, sortIdentities } from "@/lib/identity-order"; import type { AccountInfo } from "./types"; import { stalwartChangeId } from "./stalwart-state"; import { downloadNameSegment } from "./download-name"; -import { getOfflineHooks, isNetworkFailure, OfflineUnavailableError } from "./offline-hooks"; +import { getOfflineHooks, isNetworkFailure, OfflineUnavailableError, SendNotStartedError } from "./offline-hooks"; import { toInertBlob } from "@/lib/file-preview"; import { parseAuthenticationResults } from "@/lib/email-headers"; import { IS_LITE_STALWART } from "@/lib/lite"; @@ -5006,10 +5006,10 @@ export class JMAPClient implements IJMAPClient { ): Promise { const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined; const emailId = `send-${Date.now()}`; - const targetAccountId = await this.resolveSendAccountId(fromEmail); - const mboxResp = await this.request([ + const targetAccountId = await this.beforeSubmission(() => this.resolveSendAccountId(fromEmail)); + const mboxResp = await this.beforeSubmission(() => this.request([ ["Mailbox/get", { accountId: targetAccountId, properties: ["id", "role"] }, "0"] - ]); + ])); const mailboxes = (mboxResp.methodResponses?.[0]?.[1]?.list || []) as Mailbox[]; const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); if (!sentMailbox) { @@ -5026,9 +5026,9 @@ export class JMAPClient implements IJMAPClient { let identityEmail: string | undefined; let identityName: string | undefined; { - const identityResponse = await this.request([ + const identityResponse = await this.beforeSubmission(() => this.request([ ["Identity/get", { accountId: targetAccountId }, "0"] - ]); + ])); if (!finalIdentityId) { finalIdentityId = targetAccountId; @@ -5218,7 +5218,7 @@ export class JMAPClient implements IJMAPClient { // bounce while the create succeeds and files the message in Sent. methodCalls.push(deliveryStatusCall(submissionAccountId, "1")); - let response = await this.request(methodCalls); + let response = await this.submissionRequest(methodCalls); // The message exists in Drafts from here on, sent or not. Two refusals of // the submission can be worked around by submitting that copy once more: @@ -6101,25 +6101,36 @@ export class JMAPClient implements IJMAPClient { // fetch path is kept for callers that don't need either, to preserve // existing 401/retry behaviour through authenticatedFetch(). let responseText: string; - if (opts.onProgress || opts.signal) { - await this.confirmTokenBeforeXhr(); - responseText = await this.xhrUpload( - finalUploadUrl, - file, - opts.onProgress, - opts.signal, - ); - } else { - const response = await this.authenticatedFetch(finalUploadUrl, { - method: 'POST', - headers: { 'Content-Type': file.type || 'application/octet-stream' }, - body: file, - }, { timeoutMs: JMAPClient.TRANSFER_TIMEOUT_MS }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to upload file: ${response.status} - ${errorText}`); + try { + if (opts.onProgress || opts.signal) { + await this.confirmTokenBeforeXhr(); + responseText = await this.xhrUpload( + finalUploadUrl, + file, + opts.onProgress, + opts.signal, + ); + } else { + const response = await this.authenticatedFetch(finalUploadUrl, { + method: 'POST', + headers: { 'Content-Type': file.type || 'application/octet-stream' }, + body: file, + }, { timeoutMs: JMAPClient.TRANSFER_TIMEOUT_MS }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to upload file: ${response.status} - ${errorText}`); + } + responseText = await response.text(); } - responseText = await response.text(); + } catch (error) { + // The server is gone though the browser says it's online (#523): the + // file waits on this device as when offline. Asking whether it is + // gone tells an outage from a refusal (a too-large file, a quota). + if (offline && !opts.signal?.aborted && await offline.serverGone(this)) { + const held = await offline.holdUpload(this, file, targetAccountId); + if (held) return held; + } + throw error; } let result; try { @@ -6561,15 +6572,49 @@ export class JMAPClient implements IJMAPClient { } private async getEmailSubmissionSendAt(submissionId: string, accountId?: string): Promise { - const response = await this.request([ - ['EmailSubmission/get', { - accountId: accountId ?? await this.resolveSubmissionAccountId(submissionId), - ids: [submissionId], - properties: ['sendAt', 'undoStatus'], - }, '0'], - ]); - const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { sendAt?: string } | undefined; - return submission?.sendAt; + // Read back after the submission: the message is on its way whatever + // happens here, so a failure (the network gone a moment later) must + // never turn the send into a failed one (#523). + try { + const response = await this.request([ + ['EmailSubmission/get', { + accountId: accountId ?? await this.resolveSubmissionAccountId(submissionId), + ids: [submissionId], + properties: ['sendAt', 'undoStatus'], + }, '0'], + ]); + const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { sendAt?: string } | undefined; + return submission?.sendAt; + } catch (error) { + debug.warn('email', 'Could not read back the hold of a sent message:', error); + return undefined; + } + } + + /** + * A step of a send before the submission request: when it fails on the + * network nothing has left, which the offline outbox has to know to try + * again instead of marking the message "may have gone out" (#523). + */ + private async beforeSubmission(step: () => Promise): Promise { + try { + return await step(); + } catch (error) { + if (isNetworkFailure(error) || (error instanceof Error && (error.name === 'RequestTimeoutError' || error.name === 'OfflineUnavailableError'))) { + throw new SendNotStartedError(error); + } + throw error; + } + } + + /** The request that sends: only a refusal without touching the network means it didn't start. */ + private async submissionRequest(methodCalls: JMAPMethodCall[]): Promise { + try { + return await this.request(methodCalls); + } catch (error) { + if (error instanceof Error && error.name === 'OfflineUnavailableError') throw new SendNotStartedError(error); + throw error; + } } private async getEmailSubmissionForReschedule(submissionId: string, accountId?: string): Promise<{ identityId?: string; envelope?: { rcptTo?: Array<{ email: string }> }; sendAt?: string } | undefined> { @@ -11549,12 +11594,12 @@ export class JMAPClient implements IJMAPClient { const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined; // Upload the raw message const file = new File([blob], 'message.eml', { type: 'message/rfc822' }); - const { blobId } = await this.uploadBlob(file); + const { blobId } = await this.beforeSubmission(() => this.uploadBlob(file)); // Import into Drafts first, then move to Sent after submission succeeds. // This avoids encrypt-on-append affecting the SMTP send. See #188. const importMailboxId = draftMailboxId || sentMailboxId; - const identities = await this.getIdentities(); + const identities = await this.beforeSubmission(() => this.getIdentities()); const identity = identities.find(item => item.id === identityId); const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients); // These raw/S-MIME paths submit through the primary submission account, so @@ -11616,7 +11661,7 @@ export class JMAPClient implements IJMAPClient { delayedUntil?: string, envelopeRecipients?: string[], ): Promise { - const mailboxes = await this.getMailboxes(); + const mailboxes = await this.beforeSubmission(() => this.getMailboxes()); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); if (!draftsMailbox) { throw new Error('Drafts mailbox not found'); @@ -11625,9 +11670,9 @@ export class JMAPClient implements IJMAPClient { const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined; const file = new File([blob], 'message.eml', { type: 'message/rfc822' }); - const { blobId } = await this.uploadBlob(file); + const { blobId } = await this.beforeSubmission(() => this.uploadBlob(file)); - const identities = await this.getIdentities(); + const identities = await this.beforeSubmission(() => this.getIdentities()); const identity = identities.find(item => item.id === identityId); const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients); const submissionAccountId = this.getSubmissionAccountId(); @@ -11692,7 +11737,7 @@ export class JMAPClient implements IJMAPClient { failureMessage: string, ): Promise<{ emailId?: string; submissionId?: string; sendAt?: string; rejected: RejectedRecipient[] }> { const firstSet = submissionSet(`#${importCreationId}`); - let response = await this.request([ + let response = await this.submissionRequest([ importCall, ['EmailSubmission/set', firstSet, '1'], deliveryStatusCall(submissionAccountId, 'raw-submit'), @@ -11762,7 +11807,12 @@ export class JMAPClient implements IJMAPClient { */ private async destroyImmediateSubmission(submissionId: string | undefined, accountId: string, delayedUntil?: string): Promise { if (delayedUntil || !submissionId || !this.hasAccountCapability('urn:stalwart:jmap', accountId)) return; - await this.destroyEmailSubmissions([submissionId], accountId); + // After the message left: a failure here leaves a record, not a failed send. + try { + await this.destroyEmailSubmissions([submissionId], accountId); + } catch (error) { + debug.warn('email', 'Could not remove the submission record of a sent message:', error); + } } async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; totalByAccount?: Record; nextPosition: number }> { diff --git a/lib/jmap/offline-hooks.ts b/lib/jmap/offline-hooks.ts index 78cd2a869..ab0477fc9 100644 --- a/lib/jmap/offline-hooks.ts +++ b/lib/jmap/offline-hooks.ts @@ -73,3 +73,15 @@ export class OfflineUnavailableError extends Error { this.name = 'OfflineUnavailableError'; } } + +/** + * A send failed before its submission request reached the network: nothing + * left, so the offline outbox may keep it and try again (#523). Any other + * failure of a send may have come after the server took the message. + */ +export class SendNotStartedError extends Error { + constructor(readonly cause?: unknown) { + super(cause instanceof Error ? cause.message : 'The send did not start'); + this.name = 'SendNotStartedError'; + } +} diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 09c477776..13f5a0c36 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -118,6 +118,8 @@ export interface SendEmailResult { * server can be reached again. Nothing else is set then. */ queued?: boolean; + /** The outbox entry, when queued (see lib/offline/offline-mode.ts afterOutboxSend). */ + outboxId?: number; emailId?: string; emailSubmissionId?: string; sendAt?: string; diff --git a/lib/offline/__tests__/cache.test.ts b/lib/offline/__tests__/cache.test.ts index 50aed3c07..53ffced76 100644 --- a/lib/offline/__tests__/cache.test.ts +++ b/lib/offline/__tests__/cache.test.ts @@ -16,7 +16,7 @@ import { wipeAccountCache, } from '../cache'; import { forgetDeviceKey, openJson, sealJson } from '../crypto'; -import { deleteOfflineDb, idbGet, idbGetAll } from '../db'; +import { allowOfflineDbCreation, deleteOfflineDb, idbGet, idbGetAll } from '../db'; const day = 24 * 60 * 60 * 1000; const email = (id: string, ageDays: number, extra: Record = {}) => ({ @@ -33,6 +33,7 @@ beforeEach(async () => { forgetIndexes(); forgetDeviceKey(); await deleteOfflineDb(); + allowOfflineDbCreation(); }); describe('offline encryption', () => { diff --git a/lib/offline/__tests__/client-fallback.test.ts b/lib/offline/__tests__/client-fallback.test.ts index 4829e2c91..d710d1c77 100644 --- a/lib/offline/__tests__/client-fallback.test.ts +++ b/lib/offline/__tests__/client-fallback.test.ts @@ -8,7 +8,7 @@ import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; import { startOfflineMode, wipeEverything } from '../offline-mode'; import { forgetIndexes, putEmails, putMisc, emailIndex } from '../cache'; import { forgetDeviceKey } from '../crypto'; -import { deleteOfflineDb } from '../db'; +import { allowOfflineDbCreation, deleteOfflineDb } from '../db'; import { useOfflineStore, writeEnabled } from '../state'; import { syncAccount } from '../sync'; @@ -30,10 +30,11 @@ beforeEach(async () => { forgetIndexes(); forgetDeviceKey(); await deleteOfflineDb(); + allowOfflineDbCreation(); localStorage.clear(); probeOk = true; apiFails = false; - useOfflineStore.setState({ networkDown: false, browserOffline: false }); + useOfflineStore.setState({ downServers: [], browserOffline: false }); usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } }); useAccountStore.setState({ accounts: [{ id: 'acc', serverUrl: SERVER, username: 'alice@example.org' } as never] }); vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { @@ -69,7 +70,7 @@ describe('falling back to the device', () => { probeOk = false; const email = await client.getEmail('m1'); expect(email?.subject).toBe('Kept'); - expect(useOfflineStore.getState().networkDown).toBe(true); + expect(useOfflineStore.getState().downServers).toEqual(['https://mail.example.org']); stop(); }); @@ -82,7 +83,7 @@ describe('falling back to the device', () => { probeOk = true; // getEmail swallows errors into null: the device's copy isn't passed off as the server's answer. expect(await client.getEmail('m1')).toBeNull(); - expect(useOfflineStore.getState().networkDown).toBe(false); + expect(useOfflineStore.getState().downServers).toEqual([]); stop(); }); }); diff --git a/lib/offline/__tests__/client-send.test.ts b/lib/offline/__tests__/client-send.test.ts new file mode 100644 index 000000000..05f6e003e --- /dev/null +++ b/lib/offline/__tests__/client-send.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '@/lib/jmap/client'; + +// The outbox (#523) may only try a send again when nothing reached the +// server: the client says so with SendNotStartedError for failures before +// the submission request, and never for one during or after it. + +const SERVER = 'https://mail.example.org'; +const SESSION = { + apiUrl: `${SERVER}/jmap/`, + downloadUrl: `${SERVER}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`, + uploadUrl: `${SERVER}/jmap/upload/{accountId}/`, + accounts: { j1: { name: 'alice', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: { FUTURERELEASE: [] } } } } }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'j1', 'urn:ietf:params:jmap:submission': 'j1' }, + capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500 }, 'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600 } }, + username: 'alice@example.org', +}; + +type Mode = { mailboxFails?: boolean; submissionFails?: boolean; readBackFails?: boolean }; +let mode: Mode = {}; +const submissions: unknown[] = []; + +function answer(body: unknown) { + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); +} + +beforeEach(() => { + mode = {}; + submissions.length = 0; + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + if (String(url).endsWith('/.well-known/jmap')) return answer(SESSION); + const request = JSON.parse(String(init?.body ?? '{}')) as { methodCalls: Array<[string, Record, string]> }; + const names = request.methodCalls.map(([name]) => name); + if (names.includes('EmailSubmission/set')) { + submissions.push(request); + if (mode.submissionFails) throw new TypeError('Failed to fetch'); + const create = request.methodCalls.find(([name]) => name === 'Email/set')?.[1].create as Record; + const key = Object.keys(create)[0]; + return answer({ methodResponses: [ + ['Email/set', { accountId: 'j1', created: { [key]: { id: 'e1' } } }, '0'], + ['EmailSubmission/set', { accountId: 'j1', created: { 1: { id: 'sub1' } } }, '1'], + ] }); + } + if (names[0] === 'Mailbox/get') { + if (mode.mailboxFails) throw new TypeError('Failed to fetch'); + return answer({ methodResponses: [['Mailbox/get', { accountId: 'j1', list: [{ id: 's', role: 'sent' }, { id: 'd', role: 'drafts' }] }, '0']] }); + } + if (names[0] === 'Identity/get') { + return answer({ methodResponses: [['Identity/get', { accountId: 'j1', list: [{ id: 'i1', email: 'alice@example.org', name: 'Alice' }] }, '0']] }); + } + if (names[0] === 'EmailSubmission/get') { + if (mode.readBackFails) throw new TypeError('Failed to fetch'); + return answer({ methodResponses: [['EmailSubmission/get', { accountId: 'j1', list: [{ id: 'sub1', sendAt: '2030-01-01T00:00:00Z' }] }, '0']] }); + } + return answer({ methodResponses: names.map((name, i) => [name, { accountId: 'j1' }, String(i)]) }); + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +async function client(): Promise { + const c = new JMAPClient(SERVER, 'alice@example.org', 'pw'); + await c.connect(); + return c; +} + +const send = (c: JMAPClient, delayedUntil?: string) => c.sendEmail(['bob@example.org'], 'Hi', 'Body', undefined, undefined, 'i1', 'alice@example.org', undefined, 'Alice', undefined, undefined, undefined, undefined, delayedUntil); + +describe('telling a send that never started from one that may have gone', () => { + it('a dead network before the submission: SendNotStartedError, nothing submitted', async () => { + const c = await client(); + mode.mailboxFails = true; + await expect(send(c)).rejects.toMatchObject({ name: 'SendNotStartedError' }); + expect(submissions).toHaveLength(0); + }); + + it('a dead network during the submission: not "not started"', async () => { + const c = await client(); + mode.submissionFails = true; + const error = await send(c).catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect((error as Error).name).not.toBe('SendNotStartedError'); + expect(submissions).toHaveLength(1); + }); + + it('a held send whose hold can’t be read back afterwards still counts as sent', async () => { + const c = await client(); + mode.readBackFails = true; + const result = await send(c, new Date(Date.now() + 120_000).toISOString()); + expect(result.scheduled).toBe(true); + expect(result.emailSubmissionId).toBe('sub1'); + }); +}); diff --git a/lib/offline/__tests__/outbox.test.ts b/lib/offline/__tests__/outbox.test.ts index 4a00ee069..141217fdb 100644 --- a/lib/offline/__tests__/outbox.test.ts +++ b/lib/offline/__tests__/outbox.test.ts @@ -11,7 +11,7 @@ import { flushQueue } from '../flush'; import { holdFile, queuePatch, readQueue } from '../queue'; import { emailIndex, forgetIndexes, putEmails } from '../cache'; import { forgetDeviceKey } from '../crypto'; -import { deleteOfflineDb } from '../db'; +import { allowOfflineDbCreation, deleteOfflineDb } from '../db'; import { useOfflineStore, writeEnabled, OFFLINE_ACCOUNTS_KEY } from '../state'; import type { JMAPClient } from '@/lib/jmap/client'; @@ -44,8 +44,9 @@ beforeEach(async () => { forgetIndexes(); forgetDeviceKey(); await deleteOfflineDb(); + allowOfflineDbCreation(); localStorage.clear(); - useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, browserOffline: false, networkDown: false, startedOffline: false }); + useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, browserOffline: false, downServers: [], startedOffline: false }); usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } }); useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] }); }); @@ -143,10 +144,11 @@ describe('the outbox', () => { await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); let { outbox } = await readQueue('acc'); expect(outbox[0].status).toBe('failed'); - expect(outbox[0].error).toBe('Recipient address rejected'); + expect(outbox[0].error).toEqual({ text: 'Recipient address rejected' }); const cut = fakeClient({ sendEmail: vi.fn(async () => { throw new TypeError('Failed to fetch'); }) }); await wipeEverything(); + allowOfflineDbCreation(); writeEnabled(['acc']); useAuthStore.setState({ client: cut as never, activeAccountId: 'acc' }); setOnline(false); @@ -178,10 +180,8 @@ describe('wiping offline data', () => { usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, noOffline: true } } } }); enforcePolicy(); await vi.waitFor(async () => expect(useOfflineStore.getState().enabled).toEqual([])); - await vi.waitFor(async () => { - forgetIndexes(); - expect((await emailIndex('acc')).size).toBe(0); - }); + // Its only account: the whole database goes. + await vi.waitFor(async () => expect((await indexedDB.databases()).map((d) => d.name)).not.toContain('bulwark-offline')); expect(localStorage.getItem(OFFLINE_ACCOUNTS_KEY)).toBeNull(); }); diff --git a/lib/offline/__tests__/responder.test.ts b/lib/offline/__tests__/responder.test.ts index 3d0e1e148..b8617fdd1 100644 --- a/lib/offline/__tests__/responder.test.ts +++ b/lib/offline/__tests__/responder.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { answerOffline, type ResponderContext } from '../responder'; import { emailIndex, emailKey, forgetIndexes, putEmails, putMisc } from '../cache'; import { forgetDeviceKey } from '../crypto'; -import { deleteOfflineDb } from '../db'; +import { allowOfflineDbCreation, deleteOfflineDb } from '../db'; const day = 24 * 60 * 60 * 1000; const at = (ageDays: number) => new Date(Date.now() - ageDays * day).toISOString(); @@ -21,6 +21,7 @@ beforeEach(async () => { forgetIndexes(); forgetDeviceKey(); await deleteOfflineDb(); + allowOfflineDbCreation(); queued = []; refused = []; await putEmails('acc', 'j1', [ diff --git a/lib/offline/__tests__/review-fixes.test.ts b/lib/offline/__tests__/review-fixes.test.ts new file mode 100644 index 000000000..db4e4e7ab --- /dev/null +++ b/lib/offline/__tests__/review-fixes.test.ts @@ -0,0 +1,342 @@ +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAuthStore } from '@/stores/auth-store'; +import { useAccountStore } from '@/stores/account-store'; +import { useEmailStore } from '@/stores/email-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { runBeforeSignOut } from '@/lib/sign-out-cleanup'; +import { setSendGuardSource } from '@/lib/plugin-send-guards'; +import { clearAllHooks, emailHooks } from '@/lib/plugin-hooks'; +import { beginComposeTakeover, completesComposeTakeover, endComposeTakeover, resetComposeTakeovers } from '@/lib/outgoing/compose-takeover'; +import type { JMAPClient } from '@/lib/jmap/client'; +import type { InstalledPlugin } from '@/lib/plugin-types'; +import { + confirmLoss, + enforcePolicy, + inScope, + offlineSessionFor, + pluginsNotRunning, + recordAnswer, + scopeOf, + startOfflineMode, + stricterNow, + wipeEverything, +} from '../offline-mode'; +import { flushQueue, settleStaleSends, SENDING_STALE_MS } from '../flush'; +import { claimForSending, holdFile, queueSend, readQueue } from '../queue'; +import { emailIndex, emailKey, forgetIndexes, getBlob, messageOfBlob, putBlob, putEmails, putMisc, removeEmails } from '../cache'; +import { forgetDeviceKey } from '../crypto'; +import { allowOfflineDbCreation, deleteOfflineDb, idbGetAll, openOfflineDb } from '../db'; +import { consumeLost } from '../lost'; +import { shellFiles } from '../shell'; +import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, setServerDown, useOfflineStore, writeEnabled } from '../state'; + +const day = 24 * 60 * 60 * 1000; +const recent = () => new Date(Date.now() - day).toISOString(); + +function fakeClient(server = 'https://mail.example.org', overrides: Record = {}) { + return { + getServerUrl: () => server, + getUsername: () => 'alice@example.org', + getAccountId: () => 'j1', + isOfflineOnly: () => false, + getSessionSnapshot: () => ({ apiUrl: `${server}/jmap` }), + sendEmail: vi.fn(async () => ({ scheduled: false, emailId: 'sent-1', emailSubmissionId: 'sub-1' })), + uploadBlob: vi.fn(async (file: File) => ({ blobId: `server-${file.name}`, size: file.size, type: file.type })), + applyEmailPatches: vi.fn(async () => []), + pruneEmailSubmissions: vi.fn(async () => {}), + ...overrides, + }; +} + +function setPolicyOrg(organisation: Record) { + usePolicyStore.setState({ loaded: true, source: 'server', subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, ...organisation } as never } }); +} + +function setOnline(online: boolean) { + Object.defineProperty(window.navigator, 'onLine', { configurable: true, get: () => online }); +} + +let stop: () => void = () => {}; + +beforeEach(async () => { + forgetIndexes(); + forgetDeviceKey(); + await deleteOfflineDb(); + allowOfflineDbCreation(); + localStorage.clear(); + sessionStorage.clear(); + setOnline(true); + useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, browserOffline: false, downServers: [], startedOffline: false }); + setPolicyOrg({}); + useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] }); + useAuthStore.setState({ isAuthenticated: true, activeAccountId: 'acc', client: null }); + setSendGuardSource(null); + resetComposeTakeovers(); +}); + +afterEach(async () => { + stop(); + stop = () => {}; + setOnline(true); + setSendGuardSource(null); + await wipeEverything(); +}); + +describe('1: two tabs never send the same message', () => { + it('only one claim wins', async () => { + const id = await queueSend('acc', { subject: 'S', to: [] }, { kind: 'email', args: [] }); + const results = await Promise.all([claimForSending(id), claimForSending(id)]); + expect(results.filter(Boolean)).toHaveLength(1); + expect((await readQueue('acc')).outbox[0].status).toBe('sending'); + }); + + it('a message another tab took isn’t sent here', async () => { + const id = await queueSend('acc', { subject: 'S', to: [] }, { kind: 'email', args: [['b@example.org'], 'S', 'x'] }); + await claimForSending(id); + const client = fakeClient(); + await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(client.sendEmail).not.toHaveBeenCalled(); + }); + + it('runs under a Web Lock per account where the browser has them', async () => { + const request = vi.fn(async (_name: string, run: () => Promise) => run()); + Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { request } }); + try { + await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(request).toHaveBeenCalledWith('bulwark-offline-flush:acc', expect.any(Function)); + } finally { + delete (window.navigator as { locks?: unknown }).locks; + } + }); +}); + +describe('2: a send that may have reached the server is never queued or sent again', () => { + it('puts a send back to waiting only when it never started', async () => { + const notStarted = Object.assign(new Error('x'), { name: 'SendNotStartedError' }); + const client = fakeClient(undefined, { sendEmail: vi.fn(async () => { throw notStarted; }) }); + await queueSend('acc', { subject: 'S', to: [] }, { kind: 'email', args: [['b@example.org'], 'S', 'x'] }); + await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect((await readQueue('acc')).outbox[0].status).toBe('waiting'); + }); + + it('the store keeps a composer send again only when it never started', async () => { + writeEnabled(['acc']); + stop = startOfflineMode(); + const args = [['b@example.org'], 'S', 'x', undefined, undefined, 'i1', 'alice@example.org', undefined, 'Alice', undefined, undefined, undefined, undefined, undefined, undefined, { outbox: true }] as const; + // The browser goes offline between the click and the submission. + const notStarted = vi.fn(async () => { setOnline(false); throw Object.assign(new Error('x'), { name: 'SendNotStartedError' }); }); + const result = await useEmailStore.getState().sendEmail(fakeClient(undefined, { sendEmail: notStarted }) as never, ...(args as unknown as [string[], string, string])); + expect(result.queued).toBe(true); + setOnline(true); + const midway = vi.fn(async () => { setOnline(false); throw new TypeError('Failed to fetch'); }); + await expect(useEmailStore.getState().sendEmail(fakeClient(undefined, { sendEmail: midway }) as never, ...(args as unknown as [string[], string, string]))).rejects.toThrow('Failed to fetch'); + expect((await readQueue('acc')).outbox).toHaveLength(1); + }); +}); + +describe('3: every sign-out the user didn’t agree to names the lost messages', () => { + it('names them for the sign-in page, unless the user agreed', async () => { + writeEnabled(['acc']); + stop = startOfflineMode(); + await queueSend('acc', { subject: 'Quarterly', to: [] }, { kind: 'email', args: [] }); + await runBeforeSignOut('all'); + expect(consumeLost()).toEqual(['Quarterly']); + + sessionStorage.clear(); + confirmLoss('all'); + await runBeforeSignOut('all'); + expect(consumeLost()).toEqual([]); + }); + + it('an account removed while another stays signed in: a notice now', async () => { + useAccountStore.setState({ accounts: [{ id: 'acc' } as never, { id: 'other' } as never] }); + writeEnabled(['acc', 'other']); + const notices: unknown[] = []; + const { setOfflineNotifier } = await import('../offline-mode'); + setOfflineNotifier((n) => notices.push(n)); + stop = startOfflineMode(); + await queueSend('acc', { subject: 'Lost one', to: [] }, { kind: 'email', args: [] }); + useAccountStore.setState({ accounts: [{ id: 'other' } as never] }); + await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'lost', subjects: ['Lost one'] })); + setOfflineNotifier(() => {}); + }); +}); + +describe('4: no queueing while a plugin isn’t running', () => { + const plugin = (status: InstalledPlugin['status']) => ({ id: 'smime', name: 'S/MIME', status, permissions: [] }) as unknown as InstalledPlugin; + const source = (plugins: InstalledPlugin[]) => ({ snapshot: () => ({ plugins, forced: () => false }), subscribe: () => () => {} }); + + it('refuses while an enabled plugin hasn’t started', async () => { + setSendGuardSource(source([plugin('enabled')])); + expect(pluginsNotRunning()).toBe(true); + writeEnabled(['acc']); + stop = startOfflineMode(); + setOnline(false); + await expect(useEmailStore.getState().sendEmail(fakeClient() as never, ['b@example.org'], 'S', 'x', undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, { outbox: true })) + .rejects.toMatchObject({ name: 'OfflinePluginsDownError' }); + expect((await readQueue('acc')).outbox).toHaveLength(0); + }); + + it('keeps it when every plugin runs, or is off', () => { + setSendGuardSource(source([plugin('running'), plugin('disabled')])); + expect(pluginsNotRunning()).toBe(false); + }); +}); + +describe('5: another tab switching it off sticks', () => { + it('follows the switch from another tab and doesn’t create the database again', async () => { + writeEnabled(['acc']); + stop = startOfflineMode(); + await putEmails('acc', 'j1', [{ id: 'm1', mailboxIds: { inbox: true }, receivedAt: recent() }]); + // The other tab: switched off, database deleted. + await deleteOfflineDb(); + window.dispatchEvent(new StorageEvent('storage', { key: OFFLINE_ACCOUNTS_KEY, newValue: null })); + expect(useOfflineStore.getState().enabled).toEqual([]); + await expect(openOfflineDb()).rejects.toMatchObject({ name: 'OfflineDbMissingError' }); + expect((await indexedDB.databases()).map((d) => d.name)).not.toContain('bulwark-offline'); + }); +}); + +describe('6: queued mail and a stricter policy', () => { + it('holds a message finished without a label once a label is required', async () => { + const id = await queueSend('acc', { subject: 'S', to: [] }, { kind: 'email', args: [] }, { labelRequired: false, hasLabel: false, guards: [] }); + const entry = (await readQueue('acc')).outbox.find((e) => e.id === id)!; + expect(stricterNow(entry)).toBe(false); + setPolicyOrg({ labels: { list: [{ id: 'c', name: 'C', color: '#000000', marking: 'C', description: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' }], defaultId: '', required: true, extraHeader: '' } }); + expect(stricterNow(entry)).toBe(true); + const client = fakeClient(); + await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true, stricterNow }); + expect(client.sendEmail).not.toHaveBeenCalled(); + expect((await readQueue('acc')).outbox[0].status).toBe('held'); + }); +}); + +describe('7: a send of a closed tab isn’t stuck', () => { + it('becomes uncertain after a minute', async () => { + const id = await queueSend('acc', { subject: 'S', to: [] }, { kind: 'email', args: [] }); + await claimForSending(id); + await settleStaleSends('acc'); + expect((await readQueue('acc')).outbox[0].status).toBe('sending'); + await settleStaleSends('acc', Date.now() + SENDING_STALE_MS + 1000); + expect((await readQueue('acc')).outbox[0].status).toBe('uncertain'); + }); +}); + +describe('8: only parts of kept messages are kept, and go with them', () => { + it('knows which message a blob belongs to and deletes it with the message', async () => { + await putEmails('acc', 'j1', [{ id: 'm1', mailboxIds: { inbox: true }, receivedAt: recent(), bodyValues: {}, attachments: [{ blobId: 'b1', name: 'a.pdf' }] }]); + expect(await messageOfBlob('acc', 'j1', 'b1')).toMatchObject({ key: emailKey('acc', 'j1', 'm1') }); + expect(await messageOfBlob('acc', 'j1', 'export-of-a-whole-message')).toBeNull(); + await putBlob('acc', 'j1', 'b1', new Blob(['x']), Date.now(), emailKey('acc', 'j1', 'm1')); + await removeEmails('acc', [emailKey('acc', 'j1', 'm1')]); + expect(await getBlob('acc', 'j1', 'b1')).toBeNull(); + }); +}); + +describe('9: folders that went away', () => { + it('drops opened folders the folder list no longer has', async () => { + await putMisc('acc', 'mailboxes', { j1: [{ id: 'inbox', role: 'inbox' }, { id: 'kept' }] }); + await putMisc('acc', 'opened', { j1: ['kept', 'deleted'] }); + expect((await scopeOf('acc')).opened).toEqual({ j1: ['kept'] }); + }); +}); + +describe('10: no decision on an account whose groups aren’t known yet', () => { + it('doesn’t wipe the active account while its subject is pending', async () => { + writeEnabled(['acc']); + setPolicyOrg({ devices: { ...DEFAULT_ORGANISATION_POLICY.devices, noOffline: true } }); + usePolicyStore.setState({ subjectPending: true }); + enforcePolicy(); + expect(useOfflineStore.getState().enabled).toEqual(['acc']); + usePolicyStore.setState({ subjectPending: false }); + enforcePolicy(); + await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual([])); + }); +}); + +describe('11: starting from the device', () => { + it('uses the policy kept from the last fetch, never the defaults', async () => { + writeEnabled(['acc']); + await putMisc('acc', 'session', { apiUrl: 'x' }); + localStorage.setItem('bulwark-policy-last', JSON.stringify({ ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, noOffline: true } } })); + usePolicyStore.setState({ loaded: false, fetching: false, source: 'none' }); + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); })); + try { + expect(await offlineSessionFor('acc')).toBeNull(); + expect(usePolicyStore.getState().source).toBe('cache'); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + +describe('12: one server down doesn’t hold another’s mail', () => { + it('counts servers apart', () => { + setServerDown('https://a.example.org', true); + expect(isOfflineNow('https://a.example.org/')).toBe(true); + expect(isOfflineNow('https://b.example.org')).toBe(false); + }); +}); + +describe('13: file names stay sealed', () => { + it('keeps no name or type of a held file readable', async () => { + await holdFile('acc', new File(['x'], 'merger-plan.pdf', { type: 'application/pdf' }), 'merger-plan.pdf'); + const records = await idbGetAll>('uploads'); + expect(JSON.stringify(records.map((r) => ({ ...r, data: undefined, iv: undefined, meta: undefined })))).not.toMatch(/merger|pdf/); + }); +}); + +describe('14: the shell of another build isn’t kept under this one', () => { + it('lists nothing when the server runs another build', async () => { + vi.stubEnv('NEXT_PUBLIC_BUILD_ID', 'build-a'); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ buildId: 'build-b', files: ['/_next/static/x.js'] })))); + try { + expect(await shellFiles()).toEqual([]); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ buildId: 'build-a', files: ['/_next/static/x.js'] })))); + expect(await shellFiles()).toEqual(['/_next/static/x.js']); + } finally { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + } + }); +}); + +describe('15: only the plugin running the composer’s send counts', () => { + it('matches the plugin and the identity of an open takeover', async () => { + const seen: boolean[] = []; + const token = beginComposeTakeover('i1'); + emailHooks.onComposeSend.register('smime', () => { + seen.push(completesComposeTakeover('smime', 'i1'), completesComposeTakeover('other', 'i1'), completesComposeTakeover('smime', 'i2')); + return false; + }); + await emailHooks.onComposeSend.takeOver({} as never); + endComposeTakeover(token); + clearAllHooks(); + expect(seen).toEqual([true, false, false]); + expect(completesComposeTakeover('smime', 'i1')).toBe(false); + }); +}); + +describe('recording: what the cache takes', () => { + it('keeps recent mail of the Inbox and opened folders, refreshes kept messages, skips the rest', async () => { + const scope = { inbox: { j1: 'inbox' }, opened: { j1: ['projects'] } }; + expect(inScope(scope, 'j1', { id: 'a', mailboxIds: { inbox: true }, receivedAt: recent() })).toBe(true); + expect(inScope(scope, 'j1', { id: 'b', mailboxIds: { projects: true }, receivedAt: recent() })).toBe(true); + expect(inScope(scope, 'j1', { id: 'c', mailboxIds: { archive: true }, receivedAt: recent() })).toBe(false); + expect(inScope(scope, 'j1', { id: 'd', mailboxIds: { inbox: true }, receivedAt: new Date(Date.now() - 40 * day).toISOString() })).toBe(false); + + await putMisc('acc', 'mailboxes', { j1: [{ id: 'inbox', role: 'inbox', name: 'Inbox' }] }); + await recordAnswer('acc', 'j1', [['Email/get', { ids: ['a', 'c'] }, '0'], ['Mailbox/get', { properties: ['id', 'role'] }, '1']], { + methodResponses: [ + ['Email/get', { accountId: 'j1', list: [{ id: 'a', mailboxIds: { inbox: true }, receivedAt: recent() }, { id: 'c', mailboxIds: { archive: true }, receivedAt: recent() }] }, '0'], + ['Mailbox/get', { accountId: 'j1', list: [{ id: 'inbox', role: 'inbox' }] }, '1'], + ], + }); + const index = await emailIndex('acc'); + expect([...index.values()].map((e) => e.id)).toEqual(['a']); + // A partial folder list (id and role only) doesn't replace the full one. + expect((await scopeOf('acc')).inbox).toEqual({ j1: 'inbox' }); + }); +}); diff --git a/lib/offline/cache.ts b/lib/offline/cache.ts index 623b53954..0411a5bde 100644 --- a/lib/offline/cache.ts +++ b/lib/offline/cache.ts @@ -22,6 +22,9 @@ export const CACHE_CAP_BYTES = 250 * 1024 * 1024; export const CACHE_DAYS = 30; export const CACHE_WINDOW_MS = CACHE_DAYS * 24 * 60 * 60 * 1000; +/** Where the metadata keeps the blob ids of a message's parts (never handed out). */ +export const BLOBS = '_bulwarkBlobs'; + /** The parts of a message that only the reader needs. */ const BODY_KEYS = ['bodyValues', 'textBody', 'htmlBody', 'bodyStructure', 'headers', 'attachments'] as const; @@ -118,6 +121,10 @@ export async function putEmails(account: string, jmap: string, emails: CachedEma const key = emailKey(account, jmap, email.id); const { meta, body } = splitBody(email); const merged = { ...(index.get(key) ?? {}), ...meta } as CachedEmail; + // The attachments and inline images of the message: only these blobs are kept (blobsOf). + if (body && Array.isArray(body.attachments)) { + merged[BLOBS] = (body.attachments as Array<{ blobId?: unknown }>).map((a) => a.blobId).filter((id): id is string => typeof id === 'string'); + } index.set(key, merged); const at = dateOf(merged); const sealed = await sealJson(merged); @@ -166,14 +173,33 @@ export async function removeEmails(account: string, keys: string[]): Promise = []; + const gone = new Set(keys); for (const key of keys) { index.delete(key); ops.push({ store: 'emails', del: key }, { store: 'bodies', del: key }); ops.push({ store: 'ledger', del: ledgerKey('emails', key) }, { store: 'ledger', del: ledgerKey('bodies', key) }); } + // Their attachments go with them. + for (const entry of await idbGetAllByAccount('ledger', account)) { + if (entry.store === 'blobs' && entry.emailKey && gone.has(entry.emailKey)) { + ops.push({ store: 'blobs', del: entry.ref }, { store: 'ledger', del: entry.key }); + } + } await idbWrite(ops); } +/** The kept message a blob (attachment, inline image) belongs to, with its date. */ +export async function messageOfBlob(account: string, jmap: string, blobId: string): Promise<{ key: string; at: number } | null> { + const index = await emailIndex(account); + const prefix = `${account}\u0001${jmap}\u0001`; + for (const [key, email] of index) { + if (!key.startsWith(prefix)) continue; + const blobs = email[BLOBS]; + if (Array.isArray(blobs) && blobs.includes(blobId)) return { key, at: dateOf(email) }; + } + return null; +} + /** A message's body parts, or null when only its list entry is kept. */ export async function getBody(account: string, jmap: string, id: string): Promise | null> { const record = await idbGet('bodies', emailKey(account, jmap, id)); @@ -192,17 +218,20 @@ export async function hasBody(account: string, jmap: string, id: string): Promis export async function putBlob(account: string, jmap: string, blobId: string, blob: Blob, at: number, ofEmail?: string): Promise { const key = blobKey(account, jmap, blobId); const sealed = await sealBytes(await blob.arrayBuffer()); + // The media type too says something about the message: sealed as well. + const meta = await sealJson({ type: blob.type }); await idbWrite([ - { store: 'blobs', put: { key, account, type: blob.type, ...sealed } }, + { store: 'blobs', put: { key, account, meta, ...sealed } }, { store: 'ledger', put: { key: ledgerKey('blobs', key), account, store: 'blobs', ref: key, at, bytes: byteSize(sealed), emailKey: ofEmail } satisfies LedgerEntry }, ]); } export async function getBlob(account: string, jmap: string, blobId: string): Promise { - const record = await idbGet('blobs', blobKey(account, jmap, blobId)); + const record = await idbGet('blobs', blobKey(account, jmap, blobId)); if (!record) return null; try { - return new Blob([await openBytes(record)], { type: record.type || 'application/octet-stream' }); + const meta = record.meta ? await openJson<{ type?: string }>(record.meta) : {}; + return new Blob([await openBytes(record)], { type: meta.type || 'application/octet-stream' }); } catch { return null; } diff --git a/lib/offline/db.ts b/lib/offline/db.ts index 765c48586..066910a49 100644 --- a/lib/offline/db.ts +++ b/lib/offline/db.ts @@ -22,6 +22,33 @@ export const STORES = ['keys', 'emails', 'bodies', 'blobs', 'misc', 'ledger', 'q export type StoreName = (typeof STORES)[number]; let dbPromise: Promise | null = null; +/** + * Only switching offline mode on may create the database. Everything else + * opens it only when it exists: another tab may have deleted it (offline + * mode switched off, a sign-out), and opening it would create it anew. + */ +let mayCreate = false; + +export function allowOfflineDbCreation(): void { + mayCreate = true; +} + +async function offlineDbExists(): Promise { + try { + if (typeof indexedDB.databases !== 'function') return true; + return (await indexedDB.databases()).some((d) => d.name === OFFLINE_DB_NAME); + } catch { + return true; + } +} + +/** Thrown when the database was deleted (or never made) and may not be created here. */ +export class OfflineDbMissingError extends Error { + constructor() { + super('The offline database is not set up'); + this.name = 'OfflineDbMissingError'; + } +} export function hasIndexedDb(): boolean { try { @@ -33,7 +60,19 @@ export function hasIndexedDb(): boolean { export function openOfflineDb(): Promise { if (dbPromise) return dbPromise; - const opening = new Promise((resolve, reject) => { + const opening = (async () => { + if (!mayCreate && !(await offlineDbExists())) throw new OfflineDbMissingError(); + return openNow(); + })(); + dbPromise = opening; + opening.catch(() => { + if (dbPromise === opening) dbPromise = null; + }); + return opening; +} + +function openNow(): Promise { + return new Promise((resolve, reject) => { const request = indexedDB.open(OFFLINE_DB_NAME, VERSION); request.onupgradeneeded = () => { const db = request.result; @@ -53,21 +92,17 @@ export function openOfflineDb(): Promise { // A sign-out in this or another tab deletes the database: let it. db.onversionchange = () => { db.close(); - if (dbPromise === opening) dbPromise = null; + dbPromise = null; + mayCreate = false; }; db.onclose = () => { - if (dbPromise === opening) dbPromise = null; + dbPromise = null; }; resolve(db); }; request.onerror = () => reject(request.error); request.onblocked = () => reject(new Error('offline database blocked')); }); - dbPromise = opening; - opening.catch(() => { - if (dbPromise === opening) dbPromise = null; - }); - return opening; } function done(tx: IDBTransaction): Promise { @@ -114,6 +149,27 @@ export async function idbWrite(ops: Array<{ store: StoreName; put?: unknown; del await done(tx); } +/** + * Reads a record and writes what `change` makes of it in one transaction; + * `change` returns undefined to leave it. True when it wrote. + */ +export async function idbUpdate(store: StoreName, key: IDBValidKey, change: (value: T | undefined) => T | undefined): Promise { + const db = await openOfflineDb(); + const tx = db.transaction(store, 'readwrite'); + const objects = tx.objectStore(store); + let wrote = false; + const read = objects.get(key); + read.onsuccess = () => { + const next = change(read.result as T | undefined); + if (next !== undefined) { + objects.put(next); + wrote = true; + } + }; + await done(tx); + return wrote; +} + export async function idbAdd(store: StoreName, value: unknown): Promise { const db = await openOfflineDb(); const tx = db.transaction(store, 'readwrite'); @@ -142,6 +198,7 @@ export async function idbDeleteAccount(account: string, stores: readonly StoreNa /** Deletes the whole database (sign-out, offline mode off everywhere). */ export async function deleteOfflineDb(): Promise { if (!hasIndexedDb()) return; + mayCreate = false; const open = dbPromise; dbPromise = null; try { diff --git a/lib/offline/flush.ts b/lib/offline/flush.ts index e8205c34b..347c0998d 100644 --- a/lib/offline/flush.ts +++ b/lib/offline/flush.ts @@ -3,55 +3,108 @@ * first the changes (read, flagged, tags, moves) in the order they were * made, then the outbox, oldest first. * + * - One tab at a time per account (Web Locks), and each message is taken + * for sending in one database transaction (claimForSending), so two tabs + * never send it twice. + * - Changes and messages go in the order they were made. * - A change the server refuses (the message is gone, moved elsewhere) is * dropped: the server's state wins. * - A message is sent as it was finished at Send (queue.ts); files attached * offline are uploaded first. A hold for undo-send that has passed is * dropped, so the message goes at once. - * - A send that fails on the server stays in the outbox, marked failed, - * with the server's reason. A send whose connection broke while it was - * on its way is marked uncertain and not tried again on its own: it may - * have gone out, and sending it twice is worse than asking. - * - When the organisation has meanwhile forbidden offline mode, nothing is - * sent: the data was wiped with the policy fetch. + * - Only a failure before the submission request (SendNotStartedError, a + * file upload on a dead network) puts a message back to waiting. A send + * whose connection broke after that is marked uncertain and never sent + * again by itself: it may have gone out. A refusal by the server is marked + * failed, with the reason. + * - A message taken for sending by a tab that was closed on the way is + * uncertain after a minute. + * - A message finished under rules the organisation has since made stricter + * (a label now required, a send check now required) is held for the user. */ import type { JMAPClient } from '@/lib/jmap/client'; import { isNetworkFailure } from '@/lib/jmap/offline-hooks'; import type { SendEmailResult } from '@/lib/jmap/types'; -import { heldFile, isLocalBlobId, readQueue, removeOutboxEntry, removeQueued, updateOutboxEntry, type OutboxEntry } from './queue'; +import { + claimForSending, + heldFile, + isLocalBlobId, + readQueue, + removeOutboxEntry, + removeQueued, + updateOutboxEntry, + type OutboxEntry, + type OutboxError, +} from './queue'; import type { OfflineNotice } from './offline-mode'; -interface FlushDeps { +export interface FlushDeps { notify: (notice: OfflineNotice) => void; allowed: () => boolean; + /** Whether the organisation's rules got stricter since this message was finished. */ + stricterNow?: (entry: OutboxEntry) => boolean; + /** A held send went out (an undo window still open). */ + onScheduled?: (entry: OutboxEntry, result: SendEmailResult) => void; } +/** A send is taken for at most this long before a closed tab's send counts as uncertain. */ +export const SENDING_STALE_MS = 60_000; + const flushing = new Set(); /** A hold shorter than this is dropped: the undo window passed while the message waited. */ const MIN_HOLD_MS = 10_000; +/** Ours to tell the user, not the server's. */ +class OutboxPrepError extends Error { + constructor(readonly code: NonNullable) { + super(code); + this.name = 'OutboxPrepError'; + } +} + +function notStartedError(message: string): Error { + return Object.assign(new Error(message), { name: 'SendNotStartedError' }); +} + function stillOffline(): boolean { return typeof navigator !== 'undefined' && navigator.onLine === false; } +/** Before anything was handed to the server: a dead network here means nothing went out. */ +function notStarted(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return error.name === 'SendNotStartedError' || error.name === 'OfflineUnavailableError'; +} + async function uploadHeld(account: string, client: JMAPClient, blobId: string): Promise { const file = await heldFile(account, blobId); - if (!file) throw new Error('A file attached offline is no longer on this device.'); - return (await client.uploadBlob(file)).blobId; + if (!file) throw new OutboxPrepError('file_missing'); + try { + return (await client.uploadBlob(file)).blobId; + } catch (error) { + if (isNetworkFailure(error)) throw notStartedError('upload failed'); + throw error; + } } async function sendEntry(account: string, client: JMAPClient, entry: OutboxEntry): Promise { const item = entry.item; if (item.kind === 'raw') { const file = await heldFile(account, item.blobId); - if (!file) throw new Error('The message is no longer on this device.'); + if (!file) throw new OutboxPrepError('file_missing'); const delayed = item.delayedUntil && Date.parse(item.delayedUntil) - Date.now() > MIN_HOLD_MS ? item.delayedUntil : undefined; if (item.submitOnly) return client.submitRawEmail(file, item.identityId, delayed, item.envelopeRecipients); - const mailboxes = await client.getMailboxes(); + let mailboxes; + try { + mailboxes = await client.getMailboxes(); + } catch (error) { + if (isNetworkFailure(error)) throw notStartedError('folders unavailable'); + throw error; + } const sent = mailboxes.find((m) => m.role === 'sent'); - if (!sent) throw new Error('No sent mailbox found'); + if (!sent) throw new OutboxPrepError('no_sent_mailbox'); const drafts = mailboxes.find((m) => m.role === 'drafts'); return client.sendRawEmail(file, item.identityId, sent.id, drafts?.id, delayed, item.envelopeRecipients); } @@ -70,53 +123,102 @@ async function sendEntry(account: string, client: JMAPClient, entry: OutboxEntry return send(...args); } +/** Sends what waits for an account, one tab at a time. */ export async function flushQueue(account: string, client: JMAPClient, deps: FlushDeps): Promise { + const run = () => flushLocked(account, client, deps); + const locks = typeof navigator !== 'undefined' ? (navigator as Navigator & { locks?: LockManager }).locks : undefined; + if (locks?.request) { + await locks.request(`bulwark-offline-flush:${account}`, run); + return; + } + await run(); +} + +/** Marks sends of closed tabs uncertain (they may have gone out). */ +export async function settleStaleSends(account?: string, now = Date.now()): Promise { + const { outbox } = await readQueue(account); + for (const entry of outbox) { + if (entry.status === 'sending' && (entry.sendingSince ?? 0) < now - SENDING_STALE_MS) { + await updateOutboxEntry(entry, { status: 'uncertain' }); + } + } +} + +async function flushLocked(account: string, client: JMAPClient, deps: FlushDeps): Promise { if (flushing.has(account) || !deps.allowed()) return; flushing.add(account); try { + await settleStaleSends(account); const { patches, outbox } = await readQueue(account); + // In the order they were made: a reply's $answered, queued after the + // reply, goes after it. + const items = [ + ...patches.map((patch) => ({ id: patch.id, patch })), + ...outbox.map((entry) => ({ id: entry.id, entry })), + ].sort((x, y) => x.id - y.id); - for (const patch of patches) { - if (stillOffline()) return; - try { - await client.applyEmailPatches(patch.item.jmap, patch.item.update); - } catch (error) { - // Still no server: try again later. Anything else: the server's state wins. - if (isNetworkFailure(error)) return; + for (const item of items) { + if ('patch' in item && item.patch) { + if (stillOffline()) return; + try { + await client.applyEmailPatches(item.patch.item.jmap, item.patch.item.update); + } catch (error) { + // Still no server: try again later. Anything else: the server's state wins. + if (isNetworkFailure(error)) return; + } + await removeQueued([item.patch.id]); + continue; } - await removeQueued([patch.id]); - } - - for (const entry of outbox) { + const entry = (item as { entry: OutboxEntry }).entry; if (entry.status !== 'waiting') continue; if (stillOffline() || !deps.allowed()) return; - await updateOutboxEntry(entry, { status: 'sending' }); + if (deps.stricterNow?.(entry)) { + await updateOutboxEntry(entry, { status: 'held' }); + deps.notify({ kind: 'held', subject: entry.summary.subject }); + continue; + } + // Another tab took it, or it was deleted meanwhile. + if (!(await claimForSending(entry.id))) continue; + let result: SendEmailResult; try { - await sendEntry(account, client, entry); - await removeOutboxEntry(entry); - void client.pruneEmailSubmissions(); - deps.notify({ kind: 'sent', subject: entry.summary.subject }); + result = await sendEntry(account, client, entry); } catch (error) { - if (isNetworkFailure(error)) { - // Offline again before anything went: it waits. Otherwise it may have gone. - await updateOutboxEntry(entry, stillOffline() ? { status: 'waiting' } : { status: 'uncertain' }); + if (notStarted(error)) { + // Nothing left: it waits for the next try. + await updateOutboxEntry(entry, { status: 'waiting' }); return; } - const message = error instanceof Error ? error.message : String(error); - await updateOutboxEntry(entry, { status: 'failed', error: message }); - deps.notify({ kind: 'send-failed', subject: entry.summary.subject, error: message }); + if (error instanceof Error && error.name === 'OutboxPrepError') { + const code = (error as OutboxPrepError).code; + await updateOutboxEntry(entry, { status: 'failed', error: { code } }); + deps.notify({ kind: 'send-failed', subject: entry.summary.subject, error: { code } }); + continue; + } + if (isNetworkFailure(error) || (error instanceof Error && (error.name === 'RequestTimeoutError' || error.name === 'SendUnconfirmedError'))) { + // It may have reached the server: never sent again by itself. + await updateOutboxEntry(entry, { status: 'uncertain' }); + return; + } + const text = error instanceof Error ? error.message : String(error); + await updateOutboxEntry(entry, { status: 'failed', error: { text } }); + deps.notify({ kind: 'send-failed', subject: entry.summary.subject, error: { text } }); + continue; } + await removeOutboxEntry(entry); + void client.pruneEmailSubmissions(); + if (result.scheduled) deps.onScheduled?.(entry, result); + deps.notify({ kind: 'sent', subject: entry.summary.subject }); } } finally { flushing.delete(account); } } -/** "Try again" for a failed or uncertain message. */ +/** "Send again" for a failed or uncertain message (a held one waits for its rules). */ export async function retryOutboxEntry(id: number): Promise { const { outbox } = await readQueue(); const entry = outbox.find((e) => e.id === id); - if (entry) await updateOutboxEntry(entry, { status: 'waiting', error: undefined }); + if (entry && (entry.status === 'failed' || entry.status === 'uncertain')) await updateOutboxEntry(entry, { status: 'waiting' }); } /** "Delete" in the outbox. */ diff --git a/lib/offline/lost.ts b/lib/offline/lost.ts new file mode 100644 index 000000000..66f91fc7e --- /dev/null +++ b/lib/offline/lost.ts @@ -0,0 +1,42 @@ +/** + * Messages of the offline outbox (#523) that a sign-out deleted unsent. + * Every sign-out the user didn't confirm for that (the idle and maximum + * session limits, a refused token renewal, an account removed in another + * way) names them: on the sign-in page, or in a toast when another account + * stays signed in. Kept in sessionStorage (this tab only, gone with it), and + * only the subjects. + */ + +const KEY = 'bulwark-offline-lost'; +const MAX = 20; + +export function rememberLost(subjects: string[]): void { + if (subjects.length === 0) return; + try { + const before = readLost(); + window.sessionStorage.setItem(KEY, JSON.stringify([...before, ...subjects].slice(-MAX))); + } catch { + /* storage unavailable */ + } +} + +function readLost(): string[] { + try { + const raw = window.sessionStorage.getItem(KEY); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === 'string') : []; + } catch { + return []; + } +} + +/** The subjects, once (the sign-in page shows them and they go). */ +export function consumeLost(): string[] { + const lost = readLost(); + try { + window.sessionStorage.removeItem(KEY); + } catch { + /* storage unavailable */ + } + return lost; +} diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 548e0e56a..0152db852 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -10,29 +10,38 @@ * and of the folders the user opened, with bodies and attachments, under * a cap (sync.ts). * - Changes and sends made offline wait in the queue (queue.ts) and go out - * in order once the server answers again (flush.ts). - * - Everything is wiped at a full sign-out and the automatic idle sign-out + * in order once the server answers again and the organisation's policy + * was fetched afresh (flush.ts). + * - Everything is wiped at a full sign-out and the automatic sign-outs * (lib/sign-out-cleanup.ts deletes the database), for one account when it * signs out or switches offline mode off, and when the organisation - * forbids it (checked on every policy fetch). + * forbids it (checked on every policy fetch). Unsent messages a sign-out + * deletes without the user's word are named (lost.ts). */ import { IS_LITE } from '@/lib/lite'; import { devicesForAccount } from '@/lib/device-policy'; +import { accountSubjectPending, useAccountSubjects } from '@/lib/org/account-subjects'; +import { labelsPolicyFor } from '@/lib/outgoing/classification'; +import { getSendGuardSource, guardState, guardsSending } from '@/lib/plugin-send-guards'; import { useAccountStore } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; import { usePolicyStore, whenPolicySettled } from '@/stores/policy-store'; import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup'; import { setOfflineHooks, type OfflineClientRef, type OfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks'; +import type { SendEmailResult } from '@/lib/jmap/types'; import { setOutboxHooks, type OutboxHooks } from './outbox-hooks'; import { answerOffline } from './responder'; import { CACHE_WINDOW_MS, cacheUsage, emailIndex, + enforceCap, forgetIndexes, getBlob, getMisc, + messageOfBlob, patchEmails, putBlob, putEmails, @@ -42,18 +51,33 @@ import { emailKey, type CachedEmail, } from './cache'; -import { deleteOfflineDb, hasIndexedDb } from './db'; +import { allowOfflineDbCreation, deleteOfflineDb, hasIndexedDb } from './db'; import { forgetDeviceKey } from './crypto'; -import { heldFile, holdFile, isLocalBlobId, queuePatch, queueSend, readQueue, wipeAccountQueue, type OutboxSummary } from './queue'; -import { isOfflineNow, useOfflineStore, writeEnabled, type OutboxRow } from './state'; -import { flushQueue } from './flush'; +import { + heldFile, + holdFile, + isLocalBlobId, + queuePatch, + queueSend, + readQueue, + wipeAccountQueue, + type OutboxEntry, + type OutboxError, + type OutboxSummary, + type PolicyFingerprint, +} from './queue'; +import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state'; +import { flushQueue, settleStaleSends } from './flush'; import { syncAccount } from './sync'; import { deleteShellCaches, tellServiceWorker } from './shell'; +import { rememberLost } from './lost'; export type OfflineNotice = | { kind: 'queued'; subject: string } | { kind: 'sent'; subject: string } - | { kind: 'send-failed'; subject: string; error: string } + | { kind: 'send-failed'; subject: string; error: OutboxError } + | { kind: 'held'; subject: string } + | { kind: 'lost'; subjects: string[] } | { kind: 'refused-delete' } | { kind: 'refused-other' } | { kind: 'forbidden' } @@ -113,23 +137,38 @@ function onlineClientFor(account: string) { return client; } +function serverOf(account: string): string | undefined { + return useAccountStore.getState().accounts.find((a) => a.id === account)?.serverUrl; +} + // ---- What the server sends, into the cache ---- -interface Scope { +export interface Scope { inbox: Record; opened: Record; } const MAX_OPENED_FOLDERS = 10; -async function scopeOf(account: string): Promise { +/** The Inbox and the opened folders that still exist, per JMAP account. */ +export async function scopeOf(account: string): Promise { const mailboxes = (await getMisc>>(account, 'mailboxes')) ?? {}; const inbox: Record = {}; for (const [jmap, list] of Object.entries(mailboxes)) { const box = list.find((m) => m.role === 'inbox'); if (box) inbox[jmap] = box.id; } - const opened = (await getMisc>(account, 'opened')) ?? {}; + const stored = (await getMisc>(account, 'opened')) ?? {}; + // A folder deleted or moved away is no longer opened. + const opened: Record = {}; + let pruned = false; + for (const [jmap, ids] of Object.entries(stored)) { + const list = mailboxes[jmap]; + const kept = list ? ids.filter((id) => list.some((m) => m.id === id)) : ids; + if (kept.length !== ids.length) pruned = true; + opened[jmap] = kept; + } + if (pruned) await putMisc(account, 'opened', opened); return { inbox, opened }; } @@ -143,7 +182,15 @@ export async function scopeMailboxes(account: string): Promise { + const out: Record = {}; + for (const [jmap, id] of Object.entries(scope.inbox)) out[jmap] = [id]; + for (const [jmap, ids] of Object.entries(scope.opened)) out[jmap] = [...(out[jmap] ?? []), ...ids]; + return out; +} + +/** A message the cache keeps: received in the last 30 days, in the Inbox or an opened folder. */ +export function inScope(scope: Scope, jmap: string, email: CachedEmail, now = Date.now()): boolean { const at = Date.parse(String(email.receivedAt ?? '')); if (Number.isFinite(at) && at < now - CACHE_WINDOW_MS) return false; const boxes = Object.keys(email.mailboxIds ?? {}); @@ -178,7 +225,11 @@ async function putList(account: string, name: 'mailboxes' | 'identities', jmap: await putMisc(account, name, all); } -async function recordAnswer(account: string, defaultJmap: string, calls: OfflineMethodCall[], response: OfflineResponse): Promise { +/** The methods whose answers the cache takes. */ +const RECORDED = new Set(['Mailbox/get', 'Identity/get', 'Email/get', 'Email/query', 'Email/set']); + +/** What the server answered, into the cache. */ +export async function recordAnswer(account: string, defaultJmap: string, calls: OfflineMethodCall[], response: OfflineResponse): Promise { const scope = await scopeOf(account); for (const [name, result, callId] of response.methodResponses) { if (!result || typeof result !== 'object') continue; @@ -201,6 +252,7 @@ async function recordAnswer(account: string, defaultJmap: string, calls: Offline const index = await emailIndex(account); const keep = list.filter((email) => index.has(emailKey(account, jmap, email.id)) || inScope(scope, jmap, email)); await putEmails(account, jmap, keep); + if (keep.length) scheduleCapCheck(); break; } case 'Email/query': { @@ -231,31 +283,57 @@ function enqueueRecord(account: string, task: () => Promise): void { recording.set(account, next); } +let capTimer: ReturnType | null = null; + +/** The cap holds between syncs too: checked shortly after writes. */ +function scheduleCapCheck(delayMs = 5_000): void { + if (capTimer) return; + capTimer = setTimeout(() => { + capTimer = null; + if (useOfflineStore.getState().enabled.length === 0) return; + void enforceCap().then(() => refreshUsage()).catch(() => {}); + }, delayMs); +} + +/** Mutating calls the device can't take: a send, an import, calendar and contact changes. */ +const NEEDS_SERVER = /^(EmailSubmission|Email\/import|CalendarEvent\/set|ContactCard\/set|AddressBook\/set|Calendar\/set|Mailbox\/set|FileNode\/set|SieveScript\/set|VacationResponse\/set|Identity\/set)/; +let lastRefusalNotice = 0; + +function noticeRefused(methodCalls: OfflineMethodCall[]): void { + if (!methodCalls.some(([name]) => NEEDS_SERVER.test(name))) return; + const now = Date.now(); + if (now - lastRefusalNotice < 5_000) return; + lastRefusalNotice = now; + notify({ kind: 'refused-other' }); +} + // ---- Hooks for the JMAP client ---- -let networkWasDown = false; -let probing: Promise | null = null; +const probes = new Map>(); /** - * A request failed on the network. Browsers report a refusal without CORS + * Whether a mail server is gone. Browsers report a refusal without CORS * headers (Stalwart answering "too many requests at once") the same way as * no network at all, so the server is asked once more without CORS: only - * when that fails too is the server counted as gone, and sends go to the - * outbox. + * when that fails too is the server counted as gone, and sends to it go to + * the outbox. Per server, so one server down doesn't hold the others' mail. */ -function probeServer(client: OfflineClientRef): Promise { - if (probing) return probing; - probing = (async () => { +export function probeServer(serverUrl: string): Promise { + const key = serverKey(serverUrl); + const running = probes.get(key); + if (running) return running; + const probe = (async () => { try { - await fetch(`${client.getServerUrl().replace(/\/+$/, '')}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit' }); + await fetch(`${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit' }); + if (setServerDown(serverUrl, false)) scheduleFlushAll(500); return false; } catch { - networkWasDown = true; - if (!useOfflineStore.getState().networkDown) useOfflineStore.setState({ networkDown: true }); + setServerDown(serverUrl, true); return true; } - })().finally(() => { probing = null; }); - return probing; + })().finally(() => { probes.delete(key); }); + probes.set(key, probe); + return probe; } const hooks: OfflineHooks = { @@ -266,40 +344,44 @@ const hooks: OfflineHooks = { async answer(client, methodCalls) { const account = accountForClient(client); if (!offlineActive(account) || !account) return null; - return answerOffline({ + const scope = await scopeOf(account).catch(() => null); + const answered = await answerOffline({ account, defaultJmap: client.getAccountId(), + keptFolders: scope ? keptFolders(scope) : undefined, queuePatch: async (jmap, update) => { await queuePatch(account, jmap, update); void refreshQueueState(); }, onRefused: (what) => notify({ kind: what === 'destroy' ? 'refused-delete' : 'refused-other' }), }, methodCalls); + if (!answered) noticeRefused(methodCalls); + return answered; }, record(client, methodCalls, response) { const account = accountForClient(client); if (!offlineActive(account) || !account) return; - // Callers change the answer's objects after this returns. - const copy = JSON.parse(JSON.stringify(response)) as OfflineResponse; - const calls = JSON.parse(JSON.stringify(methodCalls)) as OfflineMethodCall[]; + // Only the answers the cache takes; copied now, because callers change + // the answer's objects after this returns. + const wanted = response.methodResponses.filter(([name]) => RECORDED.has(name)); + if (wanted.length === 0) return; + const ids = new Set(wanted.map(([, , id]) => id)); + const copy = JSON.parse(JSON.stringify({ methodResponses: wanted })) as OfflineResponse; + const calls = JSON.parse(JSON.stringify(methodCalls.filter(([, , id]) => ids.has(id)))) as OfflineMethodCall[]; const jmap = client.getAccountId(); enqueueRecord(account, () => recordAnswer(account, jmap, calls, copy)); }, noteNetwork(client, reachable) { if (reachable) { - if (useOfflineStore.getState().networkDown) useOfflineStore.setState({ networkDown: false }); - if (networkWasDown) { - networkWasDown = false; - scheduleFlushAll(500); - } + if (setServerDown(client.getServerUrl(), false)) scheduleFlushAll(500); } else { - void probeServer(client); + void probeServer(client.getServerUrl()); } }, async serverGone(client) { if (!offlineActive(accountForClient(client))) return false; if (typeof navigator !== 'undefined' && navigator.onLine === false) return true; - return probeServer(client); + return probeServer(client.getServerUrl()); }, async blob(client, blobId, jmap) { const account = accountForClient(client); @@ -310,7 +392,14 @@ const hooks: OfflineHooks = { recordBlob(client, blobId, jmap, blob) { const account = accountForClient(client); if (!account || !offlineActive(account) || blob.size > MAX_RECORDED_BLOB_BYTES) return; - enqueueRecord(account, () => putBlob(account, jmap, blobId, blob, Date.now())); + // Only a part of a kept message (an attachment, an inline image), filed + // with it: never a whole message an export reads, never a stray file. + enqueueRecord(account, async () => { + const owner = await messageOfBlob(account, jmap, blobId); + if (!owner) return; + await putBlob(account, jmap, blobId, blob, owner.at, owner.key); + scheduleCapCheck(); + }); }, async holdUpload(client, file) { const account = accountForClient(client); @@ -320,7 +409,7 @@ const hooks: OfflineHooks = { }; /** Attachments and images opened while online are kept up to this size. */ -const MAX_RECORDED_BLOB_BYTES = 10 * 1024 * 1024; +const MAX_RECORDED_BLOB_BYTES = 5 * 1024 * 1024; // ---- The outbox ---- @@ -329,36 +418,84 @@ function summaryOf(args: unknown[]): OutboxSummary { return { subject: subject ?? '', to: [...(to ?? []), ...(cc ?? []), ...(bcc ?? [])] }; } -function queuedResult(): { scheduled: false; queued: true } { - return { scheduled: false, queued: true }; +/** Thrown instead of queueing while a plugin that may have to see the message isn't running. */ +export class OfflinePluginsDownError extends Error { + constructor() { + super('A plugin is not running offline'); + this.name = 'OfflinePluginsDownError'; + } +} + +/** + * An enabled plugin isn't running (after a start without the network the + * plugins can't load). It may be the user's signing or encryption plugin, + * or a send check, so the message isn't queued: it would go out without it. + */ +export function pluginsNotRunning(): boolean { + const source = getSendGuardSource(); + if (!source) return false; + const { plugins, pluginsOff } = source.snapshot(); + if (pluginsOff) return false; + return plugins.some((plugin) => plugin.status !== 'disabled' && plugin.status !== 'installed' && guardState(plugin) !== null); +} + +/** The send checks the organisation requires now (forced plugins that check sends). */ +function requiredGuards(): string[] { + const source = getSendGuardSource(); + if (!source) return []; + const { plugins, forced, pluginsOff } = source.snapshot(); + if (pluginsOff) return []; + return [...new Set(plugins.filter((plugin) => forced(plugin) && guardsSending(plugin)).map((plugin) => plugin.id))].sort(); +} + +export function policyFingerprint(account: string, hasLabel: boolean): PolicyFingerprint { + const labels = labelsPolicyFor({ accountId: account }); + return { labelRequired: labels.required && labels.list.length > 0, hasLabel, guards: requiredGuards() }; +} + +/** The organisation's rules got stricter since the message was finished. */ +export function stricterNow(entry: OutboxEntry): boolean { + const then = entry.fingerprint; + if (!then) return false; + const now = policyFingerprint(entry.account, then.hasLabel); + if (now.labelRequired && !then.hasLabel) return true; + return now.guards.some((id) => !then.guards.includes(id)); +} + +function queuedResult(outboxId: number): SendEmailResult { + return { scheduled: false, queued: true, outboxId }; } const outbox: OutboxHooks = { shouldQueue(client) { const account = accountForClient(client); - return !!account && offlineActive(account) && (isOfflineNow() || (typeof navigator !== 'undefined' && navigator.onLine === false)); + if (!account || !offlineActive(account)) return false; + return isOfflineNow(client.getServerUrl()) || (typeof navigator !== 'undefined' && navigator.onLine === false); }, async queueEmail(client, args) { const account = accountForClient(client); if (!account) throw new Error('No account for this send'); + if (pluginsNotRunning()) throw new OfflinePluginsDownError(); const options = { ...((args[15] as Record | undefined) ?? {}) }; delete options.outbox; const stored = [...args.slice(0, 15), options]; const summary = summaryOf(stored); - await queueSend(account, summary, { kind: 'email', args: stored }); + const labelled = !!options.labelHeaders && Object.keys(options.labelHeaders as object).length > 0; + const id = await queueSend(account, summary, { kind: 'email', args: stored }, policyFingerprint(account, labelled)); await refreshQueueState(); notify({ kind: 'queued', subject: summary.subject }); - return queuedResult(); + return queuedResult(id); }, async queueRaw(client, raw, options) { const account = accountForClient(client); if (!account) throw new Error('No account for this send'); + if (pluginsNotRunning()) throw new OfflinePluginsDownError(); const held = await holdFile(account, raw, 'message.eml'); - const summary = await rawSummary(raw); - await queueSend(account, summary, { kind: 'raw', blobId: held.blobId, ...options }); + const { summary, labelled } = await rawSummary(raw); + const id = await queueSend(account, summary, { kind: 'raw', blobId: held.blobId, ...options }, policyFingerprint(account, labelled)); await refreshQueueState(); notify({ kind: 'queued', subject: summary.subject }); - return queuedResult(); + return queuedResult(id); }, async resolveHeldAttachments(client, attachments) { const account = accountForClient(client); @@ -369,7 +506,7 @@ const outbox: OutboxHooks = { for (const attachment of attachments) { if (!isLocalBlobId(attachment.blobId)) { out.push(attachment); continue; } const file = await heldFile(account, attachment.blobId); - if (!file) throw new Error('A file attached offline is no longer on this device. Attach it again.'); + if (!file) throw Object.assign(new Error('A file attached offline is no longer on this device'), { name: 'OfflineFileMissingError' }); const { blobId } = await real.uploadBlob(file); out.push({ ...attachment, blobId }); } @@ -377,14 +514,14 @@ const outbox: OutboxHooks = { }, }; -/** Subject and recipients of a raw message, for the outbox list. */ -async function rawSummary(raw: Blob): Promise { +/** Subject, recipients and label of a raw message, for the outbox list. */ +async function rawSummary(raw: Blob): Promise<{ summary: OutboxSummary; labelled: boolean }> { const head = await raw.slice(0, 64 * 1024).text(); const end = head.search(/\r?\n\r?\n/); const block = (end >= 0 ? head.slice(0, end) : head).replace(/\r?\n[ \t]+/g, ' '); const header = (name: string) => block.split(/\r?\n/).find((line) => line.toLowerCase().startsWith(`${name}:`))?.slice(name.length + 1).trim() ?? ''; const to = ['to', 'cc'].flatMap((name) => header(name).split(',')).map((s) => s.trim()).filter(Boolean); - return { subject: decodeMimeWords(header('subject')), to }; + return { summary: { subject: decodeMimeWords(header('subject')), to }, labelled: header('sio-label') !== '' }; } function decodeMimeWords(value: string): string { @@ -410,6 +547,8 @@ export async function refreshQueueState(): Promise { return; } try { + // Sends of a tab closed on the way: uncertain, so they can be retried or deleted. + await settleStaleSends(); const { patches, outbox: entries } = await readQueue(); const rows: OutboxRow[] = entries.map((entry) => ({ id: entry.id, @@ -454,7 +593,7 @@ const syncsRunning = new Set(); export async function runSync(account: string): Promise { if (syncsRunning.has(account) || !offlineActive(account)) return; const client = onlineClientFor(account); - if (!client || isOfflineNow()) return; + if (!client || isOfflineNow(client.getServerUrl())) return; syncsRunning.add(account); useOfflineStore.setState((s) => ({ syncing: [...new Set([...s.syncing, account])] })); try { @@ -483,21 +622,52 @@ export function scheduleFlushAll(delayMs = 1_000): void { }, delayMs); } +/** How long after a failed policy fetch the outbox asks again. */ +const POLICY_RETRY_MS = 60_000; + +/** A send held by the server for undo, from the outbox: the undo toast as after any send. */ +function offerUndo(entry: OutboxEntry, result: SendEmailResult): void { + if (!result.emailSubmissionId || !result.sendAt) return; + const identityId = entry.item.kind === 'raw' ? entry.item.identityId : (entry.item.args[5] as string | undefined); + useEmailStore.setState({ + pendingUndoSend: { + submissionId: result.emailSubmissionId, + emailId: result.emailId, + identityId, + sendAt: result.sendAt, + isSmime: entry.item.kind === 'raw', + submissionAccountId: result.submissionAccountId, + localAccountId: entry.account, + }, + }); +} + export async function flushAll(): Promise { if (useOfflineStore.getState().startedOffline) { - // Started without the server: the clients have no credentials. A reload signs in and sends. - if (typeof navigator === 'undefined' || navigator.onLine !== false) notify({ kind: 'back-online-reload' }); + void leaveOfflineStart(); return; } const { outbox: rows, pendingChanges } = useOfflineStore.getState(); + if (rows.length === 0 && pendingChanges === 0) return; + // The organisation's current word before anything goes: a label now + // required, a send check now required or offline mode now forbidden must + // stop what waited under the old rules. + await usePolicyStore.getState().fetchPolicy(); + if (usePolicyStore.getState().source !== 'server') { + scheduleFlushAll(POLICY_RETRY_MS); + return; + } + enforcePolicy(); const accounts = new Set(rows.map((r) => r.account)); if (pendingChanges > 0) for (const id of useOfflineStore.getState().enabled) accounts.add(id); for (const account of accounts) { const client = onlineClientFor(account); - if (!client) continue; + if (!client || !offlineActive(account)) continue; await flushQueue(account, client, { notify, allowed: () => offlineAllowed(account), + stricterNow, + onScheduled: offerUndo, }); } await refreshQueueState(); @@ -508,6 +678,7 @@ export async function flushAll(): Promise { export async function enableOffline(account: string): Promise { if (!offlineAllowed(account)) return; + allowOfflineDbCreation(); const enabled = useOfflineStore.getState().enabled; if (!enabled.includes(account)) writeEnabled([...enabled, account]); // Ask the browser not to clear it when space runs low (it may say no). @@ -522,20 +693,66 @@ export async function enableOffline(account: string): Promise { scheduleSync(account, 0); } -/** Switches offline mode off for an account and removes everything it kept here. */ +/** Switches offline mode off for an account (the user confirmed losing unsent mail) and removes everything it kept here. */ export async function disableOffline(account: string): Promise { + confirmLoss(account); writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account)); await wipeAccount(account); } +// ---- Unsent mail a wipe deletes ---- + +// The user agreed to lose unsent mail (the sign-out question, switching +// offline mode off): no word about it afterwards. +const confirmed = new Set(); +// Already named for this sign-out. +const named = new Set(); + +export function confirmLoss(scope: string): void { + confirmed.add(scope); +} + +async function unsentSubjects(scope: string): Promise { + let rows = useOfflineStore.getState().outbox; + try { + rows = (await readQueue(scope === 'all' ? undefined : scope)).outbox.map((entry) => ({ + id: entry.id, account: entry.account, createdAt: entry.createdAt, subject: entry.summary.subject, to: entry.summary.to, status: entry.status, + })); + } catch { + // No database: what the store last saw. + } + return rows.filter((row) => scope === 'all' || row.account === scope).map((row) => row.subject); +} + +/** Names the unsent messages a wipe of `scope` deletes, unless the user agreed. */ +async function nameLost(scope: string, stillSignedIn: boolean): Promise { + if (named.has(scope) || named.has('all')) return; + if (confirmed.has(scope) || confirmed.has('all')) { + // Agreed once, for this sign-out: the wipes that follow it stay quiet too. + named.add(confirmed.has('all') ? 'all' : scope); + confirmed.delete(scope); + confirmed.delete('all'); + return; + } + const subjects = await unsentSubjects(scope); + if (subjects.length === 0) return; + named.add(scope); + rememberLost(subjects); + if (stillSignedIn) notify({ kind: 'lost', subjects }); +} + export async function wipeAccount(account: string): Promise { const timer = syncTimers.get(account); if (timer) clearTimeout(timer); syncTimers.delete(account); + await recording.get(account)?.catch(() => {}); + if (useOfflineStore.getState().enabled.includes(account) || useOfflineStore.getState().outbox.some((row) => row.account === account)) { + await nameLost(account, useAuthStore.getState().isAuthenticated); + } + named.delete(account); if (useOfflineStore.getState().enabled.includes(account)) { writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account)); } - await recording.get(account)?.catch(() => {}); // Nobody keeps mail here any more: the whole database goes (and isn't // opened again, which would create it anew). if (useOfflineStore.getState().enabled.length === 0) { @@ -562,14 +779,27 @@ export async function wipeEverything(): Promise { await deleteOfflineDb(); await tellServiceWorker(false); await deleteShellCaches(); + confirmed.clear(); + named.clear(); useOfflineStore.setState({ outbox: [], pendingChanges: 0, usageBytes: 0, syncing: [], lastSyncedAt: {} }); } +/** Whether the policy of an account is known well enough to decide on it. */ +function policyDecided(account: string): boolean { + const auth = useAuthStore.getState(); + if (account === auth.activeAccountId || !auth.activeAccountId) return !usePolicyStore.getState().subjectPending; + // Another signed-in account: its groups have to be known first (a group + // scope may forbid it); a signed-out one is wiped with its sign-out. + if (!auth.getClientForAccount?.(account)) return false; + return !accountSubjectPending(account); +} + /** The organisation forbids it for an account that has it on: wipe that account. */ export function enforcePolicy(): void { const { loaded } = usePolicyStore.getState(); if (!loaded) return; for (const account of useOfflineStore.getState().enabled) { + if (!policyDecided(account)) continue; if (!offlineAllowed(account)) { void wipeAccount(account); notify({ kind: 'forbidden' }); @@ -577,6 +807,41 @@ export function enforcePolicy(): void { } } +// ---- Started without the server ---- + +const RECONNECT_MS = 30_000; +let reconnecting = false; + +/** + * After a start without the server: once the server answers, sign in for + * real and replace the device-only clients (no reload). With a message + * being written that would close the composer, so the user is asked to + * reload instead. + */ +async function leaveOfflineStart(): Promise { + if (reconnecting || !useOfflineStore.getState().startedOffline) return; + if (typeof navigator !== 'undefined' && navigator.onLine === false) return; + reconnecting = true; + try { + const accounts = useAccountStore.getState().accounts; + const servers = [...new Set(accounts.map((a) => a.serverUrl))]; + const reachable = (await Promise.all(servers.map((url) => probeServer(url).then((gone) => !gone)))).some(Boolean); + if (!reachable) return; + const { useUIStore } = await import('@/stores/ui-store'); + if ((useUIStore.getState().composingCount ?? 0) > 0) { + notify({ kind: 'back-online-reload' }); + return; + } + const swapped = await useAuthStore.getState().reconnectOfflineClients(); + if (swapped) { + useOfflineStore.setState({ startedOffline: false }); + scheduleFlushAll(1_000); + } + } finally { + reconnecting = false; + } +} + // ---- Starting ---- let started = false; @@ -601,10 +866,35 @@ export function startOfflineMode(): () => void { window.removeEventListener('offline', onOffline); }); - // The organisation's word, on every policy fetch (and the cached copy at an offline start). + // Another tab switched it off for an account (or everyone signed out + // there): stop here too, and don't write anything for that account. + const onStorage = (event: StorageEvent) => { + if (event.key !== OFFLINE_ACCOUNTS_KEY && event.key !== null) return; + const next = readEnabledFrom(event.key === null ? null : event.newValue); + const before = useOfflineStore.getState().enabled; + const removed = before.filter((id) => !next.includes(id)); + for (const id of removed) { + const timer = syncTimers.get(id); + if (timer) clearTimeout(timer); + syncTimers.delete(id); + forgetIndexes(id); + } + if (next.length === 0) { + forgetIndexes(); + forgetDeviceKey(); + } + useOfflineStore.setState({ enabled: next }); + void refreshQueueState(); + }; + window.addEventListener('storage', onStorage); + cleanups.push(() => window.removeEventListener('storage', onStorage)); + + // The organisation's word, on every policy fetch (and the cached copy at + // an offline start), and once another account's groups are known. cleanups.push(usePolicyStore.subscribe((state, prev) => { - if (state.rawPolicy !== prev.rawPolicy || state.loaded !== prev.loaded || state.subject !== prev.subject) enforcePolicy(); + if (state.rawPolicy !== prev.rawPolicy || state.loaded !== prev.loaded || state.subject !== prev.subject || state.subjectPending !== prev.subjectPending) enforcePolicy(); })); + cleanups.push(useAccountSubjects.subscribe(() => enforcePolicy())); enforcePolicy(); // An account that signs out (or is removed) takes its offline data with it. @@ -632,12 +922,18 @@ export function startOfflineMode(): () => void { const interval = setInterval(() => { for (const account of useOfflineStore.getState().enabled) scheduleSync(account, 0); }, SYNC_INTERVAL_MS); - cleanups.push(() => clearInterval(interval)); + const reconnect = setInterval(() => { void leaveOfflineStart(); }, RECONNECT_MS); + cleanups.push(() => { + clearInterval(interval); + clearInterval(reconnect); + }); if (useOfflineStore.getState().enabled.length > 0) void tellServiceWorker(true); - void refreshQueueState().then(() => { + void (async () => { + if (useOfflineStore.getState().enabled.length > 0) await settleStaleSends().catch(() => {}); + await refreshQueueState(); if (!isOfflineNow()) scheduleFlushAll(2_000); - }); + })(); void refreshUsage(); return () => { @@ -658,18 +954,24 @@ onFullSignOut(() => { syncTimers.clear(); forgetIndexes(); forgetDeviceKey(); + confirmed.clear(); + named.clear(); useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, usageBytes: 0, syncing: [], lastSyncedAt: {}, startedOffline: false }); }); -// Unsent messages of the accounts signing out, named on the login page -// after an automatic sign-out (lib/session-policy/sign-out-reason.ts). +// Every sign-out the user didn't agree to lose mail at (the session limits, +// a refused token renewal, a removed account) names the unsent messages. onBeforeSignOut(async (accountId) => { - const rows = useOfflineStore.getState().outbox.filter((row) => accountId === 'all' || row.account === accountId); - if (rows.length === 0) return; - const { noteOutboxLost } = await import('@/lib/session-policy/sign-out-reason'); - noteOutboxLost(rows.map((row) => row.subject)); + const stillSignedIn = accountId !== 'all' && useAccountStore.getState().accounts.some((a) => a.id !== accountId); + await nameLost(accountId, stillSignedIn); }); +/** Whether the mail server can't be reached: the browser is offline or a probe fails. */ +export async function serverUnreachable(serverUrl: string): Promise { + if (typeof navigator !== 'undefined' && navigator.onLine === false) return true; + return probeServer(serverUrl); +} + /** The session kept for an account, for starting without the server. */ export async function offlineSessionFor(account: string): Promise { // The organisation's last word: the policy kept from the last fetch (the diff --git a/lib/offline/queue.ts b/lib/offline/queue.ts index 61141d6c6..61e53091a 100644 --- a/lib/offline/queue.ts +++ b/lib/offline/queue.ts @@ -9,9 +9,13 @@ * S/MIME or PGP with the user present). What is kept is the arguments of the * final send, with the files that couldn't be uploaded yet, or the finished * raw message of a plugin's send. Sending it later can't skip any of that. + * + * The state of an outbox entry (waiting, sending, ...) is kept unencrypted + * next to it, so that taking an entry for sending is one database + * transaction: two tabs can't both take it (claimForSending). */ -import { idbAdd, idbDeleteAccount, idbGet, idbGetAll, idbGetAllByAccount, idbWrite } from './db'; +import { idbAdd, idbDeleteAccount, idbGet, idbGetAll, idbGetAllByAccount, idbUpdate, idbWrite } from './db'; import { openBytes, openJson, sealBytes, sealJson, type Sealed } from './crypto'; import { generateUUID } from '@/lib/utils'; @@ -45,7 +49,28 @@ export interface OutboxSummary { to: string[]; } -export type OutboxStatus = 'waiting' | 'sending' | 'failed' | 'uncertain'; +/** + * - waiting: goes out by itself when the server answers; + * - sending: on its way (a tab took it); + * - failed: the server refused it; the error says why; + * - uncertain: the connection broke after it may have reached the server; + * never sent again by itself; + * - held: the organisation's rules got stricter while it waited. + */ +export type OutboxStatus = 'waiting' | 'sending' | 'failed' | 'uncertain' | 'held'; + +/** Why an entry failed: one of ours (a code the UI translates) or the server's own words. */ +export interface OutboxError { + code?: 'file_missing' | 'no_sent_mailbox' | 'no_drafts_mailbox'; + text?: string; +} + +/** What the organisation required when the message was finished (see offline-mode.ts policyFingerprint). */ +export interface PolicyFingerprint { + labelRequired: boolean; + hasLabel: boolean; + guards: string[]; +} export interface OutboxEntry { id: number; @@ -53,8 +78,11 @@ export interface OutboxEntry { createdAt: number; summary: OutboxSummary; status: OutboxStatus; - error?: string; + /** When a tab took it for sending (ms). */ + sendingSince?: number; + error?: OutboxError; item: SendItem; + fingerprint?: PolicyFingerprint; } interface QueueRecord extends Sealed { @@ -62,13 +90,15 @@ interface QueueRecord extends Sealed { account: string; kind: 'patch' | 'send'; createdAt: number; + status?: OutboxStatus; + sendingSince?: number; } interface SendPayload { summary: OutboxSummary; - status: OutboxStatus; - error?: string; + error?: OutboxError; item: SendItem; + fingerprint?: PolicyFingerprint; } export async function queuePatch(account: string, jmap: string, update: PatchItem['update']): Promise { @@ -76,9 +106,9 @@ export async function queuePatch(account: string, jmap: string, update: PatchIte await idbAdd('queue', { account, kind: 'patch', createdAt: Date.now(), ...(await sealJson(item)) } satisfies QueueRecord); } -export async function queueSend(account: string, summary: OutboxSummary, item: SendItem): Promise { - const payload: SendPayload = { summary, status: 'waiting', item }; - return Number(await idbAdd('queue', { account, kind: 'send', createdAt: Date.now(), ...(await sealJson(payload)) } satisfies QueueRecord)); +export async function queueSend(account: string, summary: OutboxSummary, item: SendItem, fingerprint?: PolicyFingerprint): Promise { + const payload: SendPayload = { summary, item, fingerprint }; + return Number(await idbAdd('queue', { account, kind: 'send', createdAt: Date.now(), status: 'waiting', ...(await sealJson(payload)) } satisfies QueueRecord)); } export interface QueuedPatch { id: number; account: string; item: PatchItem } @@ -95,7 +125,14 @@ export async function readQueue(account?: string): Promise<{ patches: QueuedPatc patches.push({ id: record.id!, account: record.account, item: await openJson(record) }); } else { const payload = await openJson(record); - outbox.push({ id: record.id!, account: record.account, createdAt: record.createdAt, ...payload }); + outbox.push({ + id: record.id!, + account: record.account, + createdAt: record.createdAt, + status: record.status ?? 'waiting', + sendingSince: record.sendingSince, + ...payload, + }); } } catch { // Sealed with a key that is gone; removed with the account. @@ -104,9 +141,34 @@ export async function readQueue(account?: string): Promise<{ patches: QueuedPatc return { patches, outbox }; } -export async function updateOutboxEntry(entry: OutboxEntry, patch: Partial>): Promise { - const payload: SendPayload = { summary: entry.summary, status: patch.status ?? entry.status, error: patch.error, item: entry.item }; - await idbWrite([{ store: 'queue', put: { id: entry.id, account: entry.account, kind: 'send', createdAt: entry.createdAt, ...(await sealJson(payload)) } satisfies QueueRecord }]); +/** + * Takes a waiting entry for sending, in one transaction: false when it + * isn't waiting any more (another tab took it, the user deleted it). + */ +export async function claimForSending(id: number): Promise { + return idbUpdate('queue', id, (record) => { + if (!record || record.kind !== 'send' || (record.status ?? 'waiting') !== 'waiting') return undefined; + return { ...record, status: 'sending', sendingSince: Date.now() }; + }); +} + +/** Sets an entry's state; with an error or a new after-send list, its payload too. */ +export async function updateOutboxEntry( + entry: OutboxEntry, + patch: { status: OutboxStatus; error?: OutboxError }, +): Promise { + const payload: SendPayload = { + summary: entry.summary, + item: entry.item, + fingerprint: entry.fingerprint, + error: patch.error, + }; + const sealed = await sealJson(payload); + await idbUpdate('queue', entry.id, (record) => { + // Deleted meanwhile: stays deleted. + if (!record) return undefined; + return { ...record, status: patch.status, sendingSince: patch.status === 'sending' ? Date.now() : undefined, ...sealed }; + }); } export async function removeQueued(ids: number[]): Promise { @@ -133,19 +195,29 @@ export function isLocalBlobId(blobId: unknown): blobId is string { return typeof blobId === 'string' && blobId.startsWith(LOCAL_BLOB_PREFIX); } +interface UploadRecord extends Sealed { + key: string; + account: string; + createdAt: number; + /** The file's name and type, sealed: names say a lot about a message. */ + meta: Sealed; +} + /** Keeps a file under a stand-in blob id until its message is sent. */ export async function holdFile(account: string, file: Blob, name: string): Promise<{ blobId: string; size: number; type: string }> { const blobId = `${LOCAL_BLOB_PREFIX}${generateUUID()}`; const sealed = await sealBytes(await file.arrayBuffer()); - await idbWrite([{ store: 'uploads', put: { key: uploadKey(account, blobId), account, name, type: file.type, size: file.size, createdAt: Date.now(), ...sealed } }]); + const meta = await sealJson({ name, type: file.type }); + await idbWrite([{ store: 'uploads', put: { key: uploadKey(account, blobId), account, createdAt: Date.now(), meta, ...sealed } satisfies UploadRecord }]); return { blobId, size: file.size, type: file.type || 'application/octet-stream' }; } export async function heldFile(account: string, blobId: string): Promise { - const record = await idbGet('uploads', uploadKey(account, blobId)); + const record = await idbGet('uploads', uploadKey(account, blobId)); if (!record) return null; try { - return new File([await openBytes(record)], record.name || 'attachment', { type: record.type || 'application/octet-stream' }); + const meta = await openJson<{ name?: string; type?: string }>(record.meta); + return new File([await openBytes(record)], meta.name || 'attachment', { type: meta.type || 'application/octet-stream' }); } catch { return null; } diff --git a/lib/offline/responder.ts b/lib/offline/responder.ts index f3e10f78b..d0d4ac7e7 100644 --- a/lib/offline/responder.ts +++ b/lib/offline/responder.ts @@ -13,7 +13,7 @@ * Email/changes once the server is back, which refuses it. */ -import { emailIndex, getBody, getMisc, patchEmails, putMisc, type CachedEmail } from './cache'; +import { BLOBS, emailIndex, getBody, getMisc, patchEmails, putMisc, type CachedEmail } from './cache'; import type { OfflineMethodCall, OfflineResponse } from '@/lib/jmap/offline-hooks'; export type MethodResult = Record; @@ -26,6 +26,20 @@ export interface ResponderContext { queuePatch: (jmap: string, update: Record>) => Promise; /** A change offline mode refuses (delete for good); the caller tells the user. */ onRefused: (what: 'destroy' | 'other') => void; + /** + * The folders whose mail is kept here, per JMAP account. A list of any + * other folder isn't answered (it would look empty while its count says + * otherwise). Without it, every folder is answered. + */ + keptFolders?: Record; +} + +/** The folders a filter lists, as far as it names them. */ +function filterFolders(filter: Filter | undefined | null, out: string[] = []): string[] { + if (!filter) return out; + if (typeof filter.inMailbox === 'string') out.push(filter.inMailbox); + if (Array.isArray(filter.conditions)) for (const c of filter.conditions as Filter[]) filterFolders(c, out); + return out; } class Unanswerable extends Error {} @@ -160,6 +174,10 @@ async function emailsOf(ctx: ResponderContext, jmap: string): Promise): Promise { const jmap = (args.accountId as string) || ctx.defaultJmap; if (args.anchor !== undefined) throw new Unanswerable(); + if (ctx.keptFolders) { + const kept = ctx.keptFolders[jmap] ?? []; + if (filterFolders(args.filter as Filter).some((id) => !kept.includes(id))) throw new Unanswerable(); + } let list = (await emailsOf(ctx, jmap)).filter((email) => matches(email, args.filter as Filter)); list.sort(compareBy((args.sort as Comparator[]) ?? [])); if (args.collapseThreads) { @@ -183,7 +201,11 @@ async function emailQuery(ctx: ResponderContext, args: Record): } function pick(email: Record, properties: unknown): Record { - if (!Array.isArray(properties)) return email; + if (!Array.isArray(properties)) { + const all = { ...email }; + delete all[BLOBS]; + return all; + } const out: Record = { id: email.id }; for (const prop of properties as string[]) if (prop in email) out[prop] = email[prop]; return out; @@ -319,7 +341,8 @@ async function adjustFolderCounts( const d = delta.get(box.id as string); if (!d) return box; const count = (key: string, by: number) => (typeof box[key] === 'number' ? Math.max(0, (box[key] as number) + by) : box[key]); - return { ...box, totalEmails: count('totalEmails', d.total), unreadEmails: count('unreadEmails', d.unread), totalThreads: count('totalThreads', d.total), unreadThreads: count('unreadThreads', d.unread) }; + // Messages only: how the conversations change can't be told from here. + return { ...box, totalEmails: count('totalEmails', d.total), unreadEmails: count('unreadEmails', d.unread) }; }); await putMisc(ctx.account, 'mailboxes', all); } diff --git a/lib/offline/shell.ts b/lib/offline/shell.ts index 40f902bee..88aa07b4e 100644 --- a/lib/offline/shell.ts +++ b/lib/offline/shell.ts @@ -10,11 +10,17 @@ import { currentBuildId, SW_SCOPE } from '@/lib/app-update'; /** The app's pages kept for a start without the network. */ const SHELL_PAGES = ['/', '/mail', '/settings']; -async function shellFiles(): Promise { +/** + * The files of this tab's build. After a deploy the server lists the new + * build's files: nothing then, rather than another build's files under this + * build's name. + */ +export async function shellFiles(): Promise { try { const res = await apiFetch('/api/offline/shell', { cache: 'no-store' }); if (!res.ok) return []; - const data = (await res.json()) as { files?: unknown }; + const data = (await res.json()) as { files?: unknown; buildId?: unknown }; + if (data.buildId !== currentBuildId()) return []; return Array.isArray(data.files) ? data.files.filter((f): f is string => typeof f === 'string') : []; } catch { return []; @@ -34,7 +40,10 @@ async function activeWorker(): Promise { export async function tellServiceWorker(on: boolean): Promise { const worker = await activeWorker(); if (!worker) return; - const urls = on ? [...SHELL_PAGES.map((p) => withBasePath(p)), ...(await shellFiles())] : []; + const files = on ? await shellFiles() : []; + // Without this build's files (a deploy happened), not its pages either: + // they would be the new build's. + const urls = on && files.length > 0 ? [...SHELL_PAGES.map((p) => withBasePath(p)), ...files] : []; worker.postMessage({ type: 'offline-shell', on, urls, buildId: currentBuildId() }); } diff --git a/lib/offline/state.ts b/lib/offline/state.ts index 42e82270b..14f64d32d 100644 --- a/lib/offline/state.ts +++ b/lib/offline/state.ts @@ -7,7 +7,7 @@ */ import { create } from 'zustand'; -import type { OutboxEntry } from './queue'; +import type { OutboxEntry, OutboxError } from './queue'; /** localStorage: the local account ids that keep mail on this device. */ export const OFFLINE_ACCOUNTS_KEY = 'bulwark-offline-accounts'; @@ -19,7 +19,7 @@ export interface OutboxRow { subject: string; to: string[]; status: OutboxEntry['status']; - error?: string; + error?: OutboxError; } interface OfflineState { @@ -27,8 +27,8 @@ interface OfflineState { enabled: string[]; /** The browser says it has no network. */ browserOffline: boolean; - /** The last request to the mail server failed on the network. */ - networkDown: boolean; + /** Mail servers (their URLs) that couldn't be reached at the last request. */ + downServers: string[]; /** The app started without the server, from the mail on this device. */ startedOffline: boolean; outbox: OutboxRow[]; @@ -52,7 +52,7 @@ function readEnabled(): string[] { export const useOfflineStore = create()(() => ({ enabled: readEnabled(), browserOffline: typeof navigator !== 'undefined' && navigator.onLine === false, - networkDown: false, + downServers: [], startedOffline: false, outbox: [], pendingChanges: 0, @@ -71,8 +71,31 @@ export function writeEnabled(enabled: string[]): void { } } -/** The browser is offline, or the mail server couldn't be reached. */ -export function isOfflineNow(): boolean { - const { browserOffline, networkDown } = useOfflineStore.getState(); - return browserOffline || networkDown; +export function serverKey(url: string): string { + return url.replace(/\/+$/, '').toLowerCase(); +} + +/** The browser is offline, or this mail server (any, without one) couldn't be reached. */ +export function isOfflineNow(serverUrl?: string): boolean { + const { browserOffline, downServers } = useOfflineStore.getState(); + if (browserOffline) return true; + return serverUrl ? downServers.includes(serverKey(serverUrl)) : downServers.length > 0; +} + +export function setServerDown(serverUrl: string, down: boolean): boolean { + const key = serverKey(serverUrl); + const { downServers } = useOfflineStore.getState(); + const was = downServers.includes(key); + if (was !== down) useOfflineStore.setState({ downServers: down ? [...downServers, key] : downServers.filter((s) => s !== key) }); + return was; +} + +/** The switch as another tab left it (it changed in localStorage there). */ +export function readEnabledFrom(raw: string | null): string[] { + try { + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []; + } catch { + return []; + } } diff --git a/lib/offline/sync.ts b/lib/offline/sync.ts index 0d4a918d5..c998aef45 100644 --- a/lib/offline/sync.ts +++ b/lib/offline/sync.ts @@ -55,13 +55,21 @@ export async function syncAccount(account: string, client: JMAPClient, deps: Syn const since = new Date(Date.now() - CACHE_WINDOW_MS).toISOString(); const wanted = new Map>(); const scopes = await deps.scopeMailboxes(account); - let complete = scopes.length > 0; + // Per folder: which ones were asked, and which couldn't be. + const scopeFolders = new Map>(); + const failedFolders = new Map>(); + const add = (map: Map>, jmap: string, id: string) => { + const set = map.get(jmap) ?? new Set(); + set.add(id); + map.set(jmap, set); + }; for (const { jmap, mailboxId, isInbox } of scopes) { + add(scopeFolders, jmap, mailboxId); let ids: string[]; try { ids = await twice(() => client.getRecentEmailIds(mailboxId, jmap, since, isInbox ? INBOX_LIMIT : FOLDER_LIMIT)); } catch { - complete = false; + add(failedFolders, jmap, mailboxId); continue; } const set = wanted.get(jmap) ?? new Set(); @@ -78,21 +86,27 @@ export async function syncAccount(account: string, client: JMAPClient, deps: Syn const missing: string[] = []; for (const id of ids) if (!(await hasBody(account, jmap, id))) missing.push(id); for (let i = 0; i < missing.length; i += 20) { - await twice(() => client.getFullEmails(missing.slice(i, i + 20), jmap)).catch(() => { complete = false; }); + // Fetched again at the next sync. + await twice(() => client.getFullEmails(missing.slice(i, i + 20), jmap)).catch(() => {}); if (!deps.stillActive()) return; } } await deps.recorded(); - // What left the window or the folders goes. + // What left the window or the folders goes; a folder that couldn't be + // asked keeps what it had, the others are brought up to date. const index = await emailIndex(account); const stale: string[] = []; - for (const [key, email] of index) { - const jmap = key.split('\u0001')[1]; - if (!wanted.get(jmap)?.has(email.id)) stale.push(key); + if (scopes.length > 0) { + for (const [key, email] of index) { + const jmap = key.split('\u0001')[1]; + if (wanted.get(jmap)?.has(email.id)) continue; + const folders = Object.keys(email.mailboxIds ?? {}).filter((id) => scopeFolders.get(jmap)?.has(id)); + if (folders.some((id) => failedFolders.get(jmap)?.has(id))) continue; + stale.push(key); + } } - // Only after a complete pass: a folder that couldn't be asked keeps what it had. - if (complete) await removeEmails(account, stale); + await removeEmails(account, stale); // Attachments and inline images, newest messages first, within the budget. let budget = ATTACHMENT_BUDGET_BYTES; diff --git a/lib/outgoing/compose-takeover.ts b/lib/outgoing/compose-takeover.ts index 2baf41cbc..f2a34c1d7 100644 --- a/lib/outgoing/compose-takeover.ts +++ b/lib/outgoing/compose-takeover.ts @@ -4,19 +4,38 @@ * every step at Send, with the user present, so offline mode may keep the * finished message in its outbox (#523). A plugin's own sends (a forward, a * report) aren't covered and need the server. + * + * A raw send counts only when it comes from the plugin whose onComposeSend + * handler runs right now (lib/plugin-hooks.ts pluginRunsTakeOver) and is + * for the identity of a takeover the composer opened. */ -let open = 0; +import { generateUUID } from '@/lib/utils'; +import { stripCrossAccountIdentityPrefix } from '@/hooks/use-pro-multi-account-identities'; +import { pluginRunsTakeOver } from '@/lib/plugin-hooks'; -/** Called by the composer right before it hands the send to the plugins; pair with endComposeTakeover. */ -export function beginComposeTakeover(): void { - open += 1; +const open = new Map(); + +const rawId = (identityId: string) => stripCrossAccountIdentityPrefix(identityId).rawId ?? identityId; + +/** Called by the composer right before it hands the send to the plugins; pass the token to endComposeTakeover. */ +export function beginComposeTakeover(identityId: string): string { + const token = generateUUID(); + open.set(token, rawId(identityId)); + return token; } -export function endComposeTakeover(): void { - open = Math.max(0, open - 1); +export function endComposeTakeover(token: string): void { + open.delete(token); } -export function composeTakeoverOpen(): boolean { - return open > 0; +/** A raw send from `pluginId` for `identityId` completes an open composer send. */ +export function completesComposeTakeover(pluginId: string, identityId: string): boolean { + if (!pluginRunsTakeOver(pluginId)) return false; + return [...open.values()].includes(rawId(identityId)); +} + +/** For tests. */ +export function resetComposeTakeovers(): void { + open.clear(); } diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 91b06a7ef..989e65501 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -131,6 +131,16 @@ function withTimeout(pluginId: string, promise: T | Promise, ms: number = */ export type TakeOverOutcome = 'default' | 'handled' | 'aborted' | 'failed'; +/** + * The plugins whose `takeOver` handler runs right now (onComposeSend): a raw + * send from one of them completes the composer's send (offline outbox, #523). + */ +const runningTakeOvers = new Map(); + +export function pluginRunsTakeOver(pluginId: string): boolean { + return (runningTakeOvers.get(pluginId) ?? 0) > 0; +} + /** A handler returns `{ abort: true }` to stop a `takeOver` operation. */ export function isAbortResult(value: unknown): value is { abort: true } { return typeof value === 'object' && value !== null && (value as { abort?: unknown }).abort === true; @@ -272,11 +282,16 @@ export class HookBus any> { for (const { pluginId, handler } of this.handlers) { if (pluginErrorTracker.isDisabled(pluginId)) continue; let result: unknown; + runningTakeOvers.set(pluginId, (runningTakeOvers.get(pluginId) ?? 0) + 1); try { result = await withTimeout(pluginId, handler(...args), Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS)); } catch (err) { pluginErrorTracker.record(pluginId, err); return 'failed'; + } finally { + const left = (runningTakeOvers.get(pluginId) ?? 1) - 1; + if (left > 0) runningTakeOvers.set(pluginId, left); + else runningTakeOvers.delete(pluginId); } if (result === false) return 'handled'; if (isAbortResult(result)) return 'aborted'; diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 45bedff11..7c611e4b7 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -4,7 +4,7 @@ import type { AppInfoView, InstalledPlugin, Permission } from '../plugin-types'; import { labelRawSend } from '@/lib/outgoing/classification-raw'; -import { composeTakeoverOpen } from '@/lib/outgoing/compose-takeover'; +import { completesComposeTakeover } from '@/lib/outgoing/compose-takeover'; import { getOutboxHooks } from '@/lib/offline/outbox-hooks'; import { fetchConfig } from '@/hooks/use-config'; import { IS_LITE } from '../lite'; @@ -821,6 +821,7 @@ async function doJmapSendRaw( rawBytes: ArrayBuffer | ArrayBufferView, identityId: string, opts?: JmapSubmitRawOptions, + pluginId = '', ): Promise { if (typeof identityId !== 'string' || !identityId) throw new Error('jmap.sendRaw: identityId required'); const { client } = useAuthStore.getState(); @@ -843,7 +844,7 @@ async function doJmapSendRaw( opts?.delayedUntil, opts?.envelopeRecipients, // Completing the composer's send: offline mode may keep it (#523). - { outbox: composeTakeoverOpen() }, + { outbox: completesComposeTakeover(pluginId, identityId) }, ); } @@ -855,6 +856,7 @@ async function doJmapSubmitRaw( rawBytes: ArrayBuffer | ArrayBufferView, identityId: string, opts?: JmapSubmitRawOptions, + pluginId = '', ): Promise { if (typeof identityId !== 'string' || !identityId) { throw new Error('jmap.submitRaw: identityId required'); @@ -875,7 +877,7 @@ async function doJmapSubmitRaw( const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' }); // Completing the composer's send while offline: kept in the outbox (#523). - const outbox = composeTakeoverOpen() ? getOutboxHooks() : null; + const outbox = completesComposeTakeover(pluginId, identityId) ? getOutboxHooks() : null; if (outbox?.shouldQueue(client)) { return outbox.queueRaw(client, blob, { identityId, delayedUntil: opts?.delayedUntil, envelopeRecipients: opts?.envelopeRecipients, submitOnly: true }); } @@ -2079,11 +2081,13 @@ export async function dispatchApiCall( args[0] as ArrayBuffer | ArrayBufferView, args[1] as string, args[2] as JmapSubmitRawOptions | undefined, + plugin.id, ); case 'jmap.submitRaw': return doJmapSubmitRaw( args[0] as ArrayBuffer | ArrayBufferView, args[1] as string, args[2] as JmapSubmitRawOptions | undefined, + plugin.id, ); case 'jmap.importRaw': return doJmapImportRaw( args[0] as ArrayBuffer | ArrayBufferView, diff --git a/lib/session-policy/sign-out-reason.ts b/lib/session-policy/sign-out-reason.ts index b966024f2..370b3ebb1 100644 --- a/lib/session-policy/sign-out-reason.ts +++ b/lib/session-policy/sign-out-reason.ts @@ -4,8 +4,8 @@ */ export type SessionEndReason = - | { kind: 'idle'; minutes: number; draftsUnsaved?: boolean; outboxLost?: string[] } - | { kind: 'max'; hours: number; draftsUnsaved?: boolean; outboxLost?: string[] }; + | { kind: 'idle'; minutes: number; draftsUnsaved?: boolean } + | { kind: 'max'; hours: number; draftsUnsaved?: boolean }; const KEY = 'session_end_reason'; @@ -17,23 +17,6 @@ export function rememberSessionEnd(reason: SessionEndReason): void { } } -/** - * Messages written offline that the automatic sign-out deleted unsent - * (offline mode, #523): their subjects, for the login page to name. Added - * to the reason the session timer left; a sign-out by the user (who was - * asked first) leaves none. - */ -export function noteOutboxLost(subjects: string[]): void { - try { - const raw = window.sessionStorage.getItem(KEY); - if (!raw || subjects.length === 0) return; - const reason = JSON.parse(raw) as Record; - window.sessionStorage.setItem(KEY, JSON.stringify({ ...reason, outboxLost: subjects.slice(0, 20) })); - } catch { - /* storage unavailable */ - } -} - /** Takes a reason back: an account is signed in after all. */ export function clearSessionEnd(): void { try { @@ -50,11 +33,7 @@ export function consumeSessionEnd(): SessionEndReason | null { window.sessionStorage.removeItem(KEY); if (!raw) return null; const parsed = JSON.parse(raw) as Record; - const lost = Array.isArray(parsed.outboxLost) ? parsed.outboxLost.filter((s): s is string => typeof s === 'string') : []; - const drafts = { - ...(parsed.draftsUnsaved === true ? { draftsUnsaved: true } : {}), - ...(lost.length ? { outboxLost: lost } : {}), - }; + const drafts = parsed.draftsUnsaved === true ? { draftsUnsaved: true } : {}; if (parsed.kind === 'idle' && typeof parsed.minutes === 'number') return { kind: 'idle', minutes: parsed.minutes, ...drafts }; if (parsed.kind === 'max' && typeof parsed.hours === 'number') return { kind: 'max', hours: parsed.hours, ...drafts }; return null; diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 7bd86f2ec..2b00aaf51 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -133,7 +133,9 @@ function forgetOfflineShell(): void { try { if (typeof caches === 'undefined') return; void caches.keys() - .then((keys) => Promise.all(keys.filter((key) => key.startsWith('bulwark-shell-')).map((key) => caches.delete(key)))) + // The shell, and the push notifications' state (which messages were + // shown, the quiet hours), which belongs to the accounts too. + .then((keys) => Promise.all(keys.filter((key) => key.startsWith('bulwark-shell-') || key === 'bulwark-push-state-v1').map((key) => caches.delete(key)))) .then(() => caches.open('bulwark-sw-state-v1')) .then((cache) => cache.put('/__bulwark/offline-shell', new Response(JSON.stringify({ on: false }), { headers: { 'Content-Type': 'application/json' } }))) .catch(() => {}); diff --git a/locales/ar/common.json b/locales/ar/common.json index ede0a7a99..f00aa16e1 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5667,11 +5667,15 @@ "delete": "حذف", "delete_confirm_title": "حذف هذه الرسالة؟", "delete_confirm_message": "لم تُرسل ولا يمكن استعادتها.", - "changes": "{count, plural, zero {لا تغييرات} one {تغيير واحد} two {تغييران} few {# تغييرات} many {# تغييرًا} other {# تغيير}} على حالة القراءة أو العلامات أو المجلدات ستُرسل عند عودة الاتصال." + "changes": "{count, plural, zero {لا تغييرات} one {تغيير واحد} two {تغييران} few {# تغييرات} many {# تغييرًا} other {# تغيير}} على حالة القراءة أو العلامات أو المجلدات ستُرسل عند عودة الاتصال.", + "status_held": "تغيّرت قواعد مؤسستك أثناء انتظار الرسالة. اكتبها مرة أخرى لإرسالها.", + "retry_label": "إرسال مرة أخرى: {subject}", + "delete_label": "حذف: {subject}" }, "banner": { "offline": "أنت دون اتصال. ترى البريد المحفوظ على هذا الجهاز. ما تغيّره أو تكتبه يُرسل عند عودة الاتصال.", - "outbox": "{count, plural, zero {لا رسائل} one {رسالة واحدة} two {رسالتان} few {# رسائل} many {# رسالة} other {# رسالة}} في صندوق الصادر" + "outbox": "{count, plural, zero {لا رسائل} one {رسالة واحدة} two {رسالتان} few {# رسائل} many {# رسالة} other {# رسالة}} في صندوق الصادر", + "server_down": "تعذّر الوصول إلى خادم البريد. ترى البريد المحفوظ على هذا الجهاز. ما تغيّره أو تكتبه يُرسل عندما يستجيب مرة أخرى." }, "toast": { "queued": "حُفظت في صندوق الصادر. ستُرسل عند عودة الاتصال.", @@ -5681,13 +5685,22 @@ "refused_other": "يحتاج هذا إلى اتصال. حاول مرة أخرى عندما تكون متصلًا.", "forbidden": "أوقفت مؤسستك وضع عدم الاتصال. أُزيل البريد المحفوظ على هذا الجهاز.", "back_online": "عاد الاتصال. أعد التحميل لتسجيل الدخول وإرسال ما ينتظر.", - "reload": "إعادة التحميل" + "reload": "إعادة التحميل", + "held": "رسالة في صندوق الصادر تحتاج إليك: {subject}", + "lost": "لم تُرسل هذه الرسائل وحُذفت: {subjects}" }, "signout": { "title": "تسجيل الخروج مع رسائل غير مرسلة؟", "message": "{count, plural, zero {لا رسائل غير مرسلة.} one {رسالة واحدة لم تُرسل.} two {رسالتان لم تُرسلا.} few {# رسائل لم تُرسل.} many {# رسالة لم تُرسل.} other {# رسالة لم تُرسل.}} يؤدي تسجيل الخروج إلى حذفها.", "confirm": "تسجيل الخروج", "lost": "لم تُرسل هذه الرسائل وحُذفت عند تسجيل خروجك: {subjects}" + }, + "errors": { + "unavailable": "يحتاج هذا إلى اتصال. حاول مرة أخرى عندما تكون متصلًا.", + "plugins_down": "لا تعمل إضافة التوقيع أو التشفير دون اتصال. أرسل هذا عند عودة الاتصال.", + "file_missing": "لم يعد ملف أُرفق دون اتصال موجودًا على هذا الجهاز. أرفقه مرة أخرى.", + "no_sent_mailbox": "تعذّر العثور على مجلد المرسل.", + "no_drafts_mailbox": "تعذّر العثور على مجلد المسودات." } }, "deep_link": { diff --git a/locales/ca/common.json b/locales/ca/common.json index 11125a898..bd4ddf9f5 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5667,11 +5667,15 @@ "delete": "Suprimeix", "delete_confirm_title": "Voleu suprimir aquest missatge?", "delete_confirm_message": "No s'ha enviat i no es pot recuperar.", - "changes": "{count, plural, one {# canvi} other {# canvis}} de llegit, marques o carpetes s'enviaran quan torneu a tenir connexió." + "changes": "{count, plural, one {# canvi} other {# canvis}} de llegit, marques o carpetes s'enviaran quan torneu a tenir connexió.", + "status_held": "Les normes de la vostra organització han canviat mentre esperava. Torneu-lo a escriure per enviar-lo.", + "retry_label": "Torna a enviar: {subject}", + "delete_label": "Suprimeix: {subject}" }, "banner": { "offline": "No teniu connexió. Veieu el correu desat en aquest dispositiu. El que canvieu o escriviu s'envia quan torneu a tenir connexió.", - "outbox": "{count, plural, one {# missatge} other {# missatges}} a la safata de sortida" + "outbox": "{count, plural, one {# missatge} other {# missatges}} a la safata de sortida", + "server_down": "No es pot connectar amb el servidor de correu. Veieu el correu desat en aquest dispositiu. El que canvieu o escriviu s'envia quan torni a respondre." }, "toast": { "queued": "Desat a la safata de sortida. S'envia quan torneu a tenir connexió.", @@ -5681,13 +5685,22 @@ "refused_other": "Això necessita connexió. Torneu-ho a provar quan tingueu connexió.", "forbidden": "La vostra organització ha desactivat el mode sense connexió. S'ha esborrat el correu desat en aquest dispositiu.", "back_online": "Torneu a tenir connexió. Recarregueu per iniciar la sessió i enviar el que és pendent.", - "reload": "Torna a carregar" + "reload": "Torna a carregar", + "held": "Un missatge de la safata de sortida us necessita: {subject}", + "lost": "Aquests missatges no s'han enviat i s'han esborrat: {subjects}" }, "signout": { "title": "Voleu tancar la sessió amb missatges sense enviar?", "message": "{count, plural, one {# missatge no s'ha enviat.} other {# missatges no s'han enviat.}} En tancar la sessió s'esborren.", "confirm": "Tanca la sessió", "lost": "Aquests missatges no s'han enviat i s'han esborrat en tancar la sessió: {subjects}" + }, + "errors": { + "unavailable": "Això necessita connexió. Torneu-ho a provar quan tingueu connexió.", + "plugins_down": "El vostre connector de signatura o xifratge no funciona sense connexió. Envieu-ho quan torneu a tenir connexió.", + "file_missing": "Un fitxer adjuntat sense connexió ja no és en aquest dispositiu. Torneu-lo a adjuntar.", + "no_sent_mailbox": "No s'ha trobat la carpeta Enviats.", + "no_drafts_mailbox": "No s'ha trobat la carpeta Esborranys." } }, "deep_link": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 2626ffdf3..7b2feea0e 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5667,11 +5667,15 @@ "delete": "Smazat", "delete_confirm_title": "Smazat tuto zprávu?", "delete_confirm_message": "Nebyla odeslána a nelze ji obnovit.", - "changes": "{count, plural, one {# změna} few {# změny} many {# změny} other {# změn}} přečtení, příznaků nebo složek se odešle, až budete znovu online." + "changes": "{count, plural, one {# změna} few {# změny} many {# změny} other {# změn}} přečtení, příznaků nebo složek se odešle, až budete znovu online.", + "status_held": "Pravidla vaší organizace se změnila, zatímco zpráva čekala. Napište ji znovu, abyste ji odeslali.", + "retry_label": "Odeslat znovu: {subject}", + "delete_label": "Smazat: {subject}" }, "banner": { "offline": "Jste offline. Vidíte poštu uchovanou na tomto zařízení. Co změníte nebo napíšete, se odešle, až budete znovu online.", - "outbox": "{count, plural, one {# zpráva} few {# zprávy} many {# zprávy} other {# zpráv}} k odeslání" + "outbox": "{count, plural, one {# zpráva} few {# zprávy} many {# zprávy} other {# zpráv}} k odeslání", + "server_down": "Poštovní server není dostupný. Vidíte poštu uchovanou na tomto zařízení. Co změníte nebo napíšete, se odešle, až znovu odpoví." }, "toast": { "queued": "Uloženo k odeslání. Odešle se, až budete znovu online.", @@ -5681,13 +5685,22 @@ "refused_other": "Na to je potřeba připojení. Zkuste to znovu, až budete online.", "forbidden": "Vaše organizace vypnula režim offline. Pošta uchovaná na tomto zařízení byla odstraněna.", "back_online": "Jste znovu online. Načtěte stránku znovu, abyste se přihlásili a odeslali, co čeká.", - "reload": "Načíst znovu" + "reload": "Načíst znovu", + "held": "Zpráva k odeslání potřebuje vaši pozornost: {subject}", + "lost": "Tyto zprávy nebyly odeslány a byly smazány: {subjects}" }, "signout": { "title": "Odhlásit se s neodeslanými zprávami?", "message": "{count, plural, one {# zpráva nebyla odeslána.} few {# zprávy nebyly odeslány.} many {# zprávy nebylo odesláno.} other {# zpráv nebylo odesláno.}} Odhlášením se smažou.", "confirm": "Odhlásit se", "lost": "Tyto zprávy nebyly odeslány a při odhlášení byly smazány: {subjects}" + }, + "errors": { + "unavailable": "Na to je potřeba připojení. Zkuste to znovu, až budete online.", + "plugins_down": "Váš plugin pro podepisování nebo šifrování offline neběží. Odešlete to, až budete znovu online.", + "file_missing": "Soubor přiložený offline už na tomto zařízení není. Přiložte ho znovu.", + "no_sent_mailbox": "Složka Odeslané nebyla nalezena.", + "no_drafts_mailbox": "Složka Koncepty nebyla nalezena." } }, "deep_link": { diff --git a/locales/da/common.json b/locales/da/common.json index 0bc226f3f..631bd3df5 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5667,11 +5667,15 @@ "delete": "Slet", "delete_confirm_title": "Slet denne besked?", "delete_confirm_message": "Den er ikke sendt og kan ikke hentes tilbage.", - "changes": "{count, plural, one {# ændring} other {# ændringer}} af læst, markeringer eller mapper sendes, når du er online igen." + "changes": "{count, plural, one {# ændring} other {# ændringer}} af læst, markeringer eller mapper sendes, når du er online igen.", + "status_held": "Din organisations regler blev ændret, mens beskeden ventede. Skriv den igen for at sende den.", + "retry_label": "Send igen: {subject}", + "delete_label": "Slet: {subject}" }, "banner": { "offline": "Du er offline. Du ser den mail, der er gemt på denne enhed. Det, du ændrer eller skriver, sendes, når du er online igen.", - "outbox": "{count, plural, one {# besked} other {# beskeder}} i udbakken" + "outbox": "{count, plural, one {# besked} other {# beskeder}} i udbakken", + "server_down": "Mailserveren kan ikke nås. Du ser den mail, der er gemt på denne enhed. Det, du ændrer eller skriver, sendes, når den svarer igen." }, "toast": { "queued": "Gemt i udbakken. Den sendes, når du er online igen.", @@ -5681,13 +5685,22 @@ "refused_other": "Det kræver forbindelse. Prøv igen, når du er online.", "forbidden": "Din organisation har slået offlinetilstand fra. Den mail, der var gemt på denne enhed, er fjernet.", "back_online": "Du er online igen. Genindlæs for at logge ind og sende det, der venter.", - "reload": "Genindlæs" + "reload": "Genindlæs", + "held": "En besked i udbakken venter på dig: {subject}", + "lost": "Disse beskeder blev ikke sendt og er slettet: {subjects}" }, "signout": { "title": "Log ud med usendte beskeder?", "message": "{count, plural, one {# besked er ikke sendt.} other {# beskeder er ikke sendt.}} Når du logger ud, slettes de.", "confirm": "Log ud", "lost": "Disse beskeder blev ikke sendt og blev slettet, da du blev logget ud: {subjects}" + }, + "errors": { + "unavailable": "Det kræver forbindelse. Prøv igen, når du er online.", + "plugins_down": "Dit plugin til signering eller kryptering kører ikke offline. Send dette, når du er online igen.", + "file_missing": "En fil, der blev vedhæftet offline, er ikke længere på denne enhed. Vedhæft den igen.", + "no_sent_mailbox": "Mappen Sendt blev ikke fundet.", + "no_drafts_mailbox": "Mappen Kladder blev ikke fundet." } }, "deep_link": { diff --git a/locales/de/common.json b/locales/de/common.json index 6d762db98..7142984df 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5667,27 +5667,40 @@ "delete": "Löschen", "delete_confirm_title": "Diese Nachricht löschen?", "delete_confirm_message": "Sie wurde nicht gesendet und lässt sich nicht wiederherstellen.", - "changes": "{count, plural, one {# Änderung} other {# Änderungen}} an Gelesen-Status, Markierungen oder Ordnern werden gesendet, sobald Sie wieder online sind." + "changes": "{count, plural, one {# Änderung} other {# Änderungen}} an Gelesen-Status, Markierungen oder Ordnern werden gesendet, sobald Sie wieder online sind.", + "status_held": "Die Regeln Ihrer Organisation haben sich geändert, während die Nachricht wartete. Schreiben Sie sie neu, um sie zu senden.", + "retry_label": "Erneut senden: {subject}", + "delete_label": "Löschen: {subject}" }, "banner": { "offline": "Sie sind offline. Sie sehen die auf diesem Gerät behaltenen E-Mails. Was Sie ändern oder schreiben, wird gesendet, sobald Sie wieder online sind.", - "outbox": "{count, plural, one {# Nachricht} other {# Nachrichten}} im Postausgang" + "outbox": "{count, plural, one {# Nachricht} other {# Nachrichten}} im Postausgang", + "server_down": "Der Mailserver ist nicht erreichbar. Sie sehen die auf diesem Gerät behaltenen E-Mails. Was Sie ändern oder schreiben, wird gesendet, sobald er wieder antwortet." }, "toast": { "queued": "Im Postausgang gespeichert. Die Nachricht wird gesendet, sobald Sie wieder online sind.", "sent": "Aus dem Postausgang gesendet: {subject}", "send_failed": "Eine Nachricht im Postausgang konnte nicht gesendet werden: {subject}", "refused_delete": "Endgültiges Löschen braucht eine Verbindung. Versuchen Sie es wieder, wenn Sie online sind.", - "refused_other": "Dafür braucht es eine Verbindung. Versuchen Sie es wieder, wenn Sie online sind.", + "refused_other": "Dafür ist eine Verbindung nötig. Versuchen Sie es wieder, wenn Sie online sind.", "forbidden": "Ihre Organisation hat den Offline-Modus ausgeschaltet. Die auf diesem Gerät behaltenen E-Mails wurden entfernt.", - "back_online": "Sie sind wieder online. Laden Sie neu, um sich anzumelden und Wartendes zu senden.", - "reload": "Neu laden" + "back_online": "Sie sind wieder online. Laden Sie neu, um sich anzumelden und die wartenden Nachrichten zu senden.", + "reload": "Neu laden", + "held": "Eine Nachricht im Postausgang braucht Sie: {subject}", + "lost": "Diese Nachrichten wurden nicht gesendet und gelöscht: {subjects}" }, "signout": { "title": "Mit ungesendeten Nachrichten abmelden?", "message": "{count, plural, one {# Nachricht wurde noch nicht gesendet.} other {# Nachrichten wurden noch nicht gesendet.}} Beim Abmelden werden sie gelöscht.", "confirm": "Abmelden", "lost": "Diese Nachrichten wurden nicht gesendet und beim Abmelden gelöscht: {subjects}" + }, + "errors": { + "unavailable": "Dafür ist eine Verbindung nötig. Versuchen Sie es wieder, wenn Sie online sind.", + "plugins_down": "Ihr Plugin zum Signieren oder Verschlüsseln läuft offline nicht. Senden Sie die Nachricht, wenn Sie wieder online sind.", + "file_missing": "Eine offline angehängte Datei ist nicht mehr auf diesem Gerät. Hängen Sie sie erneut an.", + "no_sent_mailbox": "Ihr Ordner „Gesendet“ wurde nicht gefunden.", + "no_drafts_mailbox": "Ihr Ordner „Entwürfe“ wurde nicht gefunden." } }, "deep_link": { diff --git a/locales/en/common.json b/locales/en/common.json index 677439902..0bde2a1b7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5667,11 +5667,15 @@ "delete": "Delete", "delete_confirm_title": "Delete this message?", "delete_confirm_message": "It hasn't been sent, and it can't be brought back.", - "changes": "{count, plural, one {# change} other {# changes}} to read state, flags or folders will be sent when you're online again." + "changes": "{count, plural, one {# change} other {# changes}} to read state, flags or folders will be sent when you're online again.", + "status_held": "Your organisation's rules changed while this waited. Write it again to send it.", + "retry_label": "Send again: {subject}", + "delete_label": "Delete: {subject}" }, "banner": { "offline": "You're offline. You see the mail kept on this device. What you change or write is sent when you're online again.", - "outbox": "{count, plural, one {# message} other {# messages}} in the outbox" + "outbox": "{count, plural, one {# message} other {# messages}} in the outbox", + "server_down": "The mail server can't be reached. You see the mail kept on this device. What you change or write is sent when it answers again." }, "toast": { "queued": "Saved in the outbox. It's sent when you're online again.", @@ -5681,13 +5685,22 @@ "refused_other": "This needs a connection. Try again when you're online.", "forbidden": "Your organisation turned off offline mode. The mail kept on this device was removed.", "back_online": "You're online again. Reload to sign in and send what's waiting.", - "reload": "Reload" + "reload": "Reload", + "held": "A message in the outbox needs you: {subject}", + "lost": "These messages weren't sent and were deleted: {subjects}" }, "signout": { "title": "Sign out with unsent mail?", "message": "{count, plural, one {# message hasn't been sent.} other {# messages haven't been sent.}} Signing out deletes them.", "confirm": "Sign out", "lost": "These messages weren't sent and were deleted when you were signed out: {subjects}" + }, + "errors": { + "unavailable": "This needs a connection. Try again when you're online.", + "plugins_down": "Your signing or encryption plugin isn't running offline. Send this when you're back online.", + "file_missing": "A file attached offline is no longer on this device. Attach it again.", + "no_sent_mailbox": "Your Sent folder couldn't be found.", + "no_drafts_mailbox": "Your Drafts folder couldn't be found." } }, "deep_link": { diff --git a/locales/es/common.json b/locales/es/common.json index 64f01f022..af4d45fd2 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5667,11 +5667,15 @@ "delete": "Eliminar", "delete_confirm_title": "¿Eliminar este mensaje?", "delete_confirm_message": "No se ha enviado y no se puede recuperar.", - "changes": "{count, plural, one {# cambio} other {# cambios}} de leído, marcas o carpetas se enviarán cuando vuelvas a tener conexión." + "changes": "{count, plural, one {# cambio} other {# cambios}} de leído, marcas o carpetas se enviarán cuando vuelvas a tener conexión.", + "status_held": "Las reglas de tu organización cambiaron mientras esperaba. Escríbelo de nuevo para enviarlo.", + "retry_label": "Enviar otra vez: {subject}", + "delete_label": "Eliminar: {subject}" }, "banner": { "offline": "No tienes conexión. Ves el correo guardado en este dispositivo. Lo que cambies o escribas se envía cuando vuelvas a tener conexión.", - "outbox": "{count, plural, one {# mensaje} other {# mensajes}} en la bandeja de salida" + "outbox": "{count, plural, one {# mensaje} other {# mensajes}} en la bandeja de salida", + "server_down": "No se puede conectar con el servidor de correo. Ves el correo guardado en este dispositivo. Lo que cambies o escribas se envía cuando vuelva a responder." }, "toast": { "queued": "Guardado en la bandeja de salida. Se envía cuando vuelvas a tener conexión.", @@ -5681,13 +5685,22 @@ "refused_other": "Esto necesita conexión. Inténtalo de nuevo cuando tengas conexión.", "forbidden": "Tu organización desactivó el modo sin conexión. Se borró el correo guardado en este dispositivo.", "back_online": "Vuelves a tener conexión. Recarga para iniciar sesión y enviar lo pendiente.", - "reload": "Recargar" + "reload": "Recargar", + "held": "Un mensaje de la bandeja de salida te necesita: {subject}", + "lost": "Estos mensajes no se enviaron y se borraron: {subjects}" }, "signout": { "title": "¿Cerrar sesión con mensajes sin enviar?", "message": "{count, plural, one {# mensaje no se ha enviado.} other {# mensajes no se han enviado.}} Al cerrar sesión se borran.", "confirm": "Cerrar sesión", "lost": "Estos mensajes no se enviaron y se borraron al cerrar la sesión: {subjects}" + }, + "errors": { + "unavailable": "Esto necesita conexión. Inténtalo de nuevo cuando tengas conexión.", + "plugins_down": "Tu plugin de firma o cifrado no funciona sin conexión. Envía esto cuando vuelvas a tener conexión.", + "file_missing": "Un archivo adjuntado sin conexión ya no está en este dispositivo. Adjúntalo de nuevo.", + "no_sent_mailbox": "No se encontró tu carpeta Enviados.", + "no_drafts_mailbox": "No se encontró tu carpeta Borradores." } }, "deep_link": { diff --git a/locales/et/common.json b/locales/et/common.json index 2c9f3cd3a..1bff84aba 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5667,11 +5667,15 @@ "delete": "Kustuta", "delete_confirm_title": "Kas kustutada see kiri?", "delete_confirm_message": "Seda pole saadetud ja seda ei saa taastada.", - "changes": "{count, plural, one {# muudatus} other {# muudatust}} loetuse, märgiste või kaustade kohta saadetakse, kui oled jälle võrgus." + "changes": "{count, plural, one {# muudatus} other {# muudatust}} loetuse, märgiste või kaustade kohta saadetakse, kui oled jälle võrgus.", + "status_held": "Sinu organisatsiooni reeglid muutusid, kuni kiri ootas. Kirjuta see saatmiseks uuesti.", + "retry_label": "Saada uuesti: {subject}", + "delete_label": "Kustuta: {subject}" }, "banner": { "offline": "Oled võrguta. Näed selles seadmes hoitud kirju. Mida muudad või kirjutad, saadetakse, kui oled jälle võrgus.", - "outbox": "{count, plural, one {# kiri} other {# kirja}} väljuvate kirjade kastis" + "outbox": "{count, plural, one {# kiri} other {# kirja}} väljuvate kirjade kastis", + "server_down": "Meiliserveriga ei saa ühendust. Näed selles seadmes hoitud kirju. Mida muudad või kirjutad, saadetakse, kui server jälle vastab." }, "toast": { "queued": "Salvestatud väljuvate kirjade kasti. See saadetakse, kui oled jälle võrgus.", @@ -5681,13 +5685,22 @@ "refused_other": "Selleks on vaja ühendust. Proovi uuesti, kui oled võrgus.", "forbidden": "Sinu organisatsioon lülitas võrguta režiimi välja. Selles seadmes hoitud kirjad eemaldati.", "back_online": "Oled jälle võrgus. Laadi uuesti, et sisse logida ja ootav saata.", - "reload": "Laadi uuesti" + "reload": "Laadi uuesti", + "held": "Väljuvate kirjade kasti kiri vajab sind: {subject}", + "lost": "Need kirjad jäid saatmata ja kustutati: {subjects}" }, "signout": { "title": "Kas logida välja saatmata kirjadega?", "message": "{count, plural, one {# kiri on saatmata.} other {# kirja on saatmata.}} Välja logimine kustutab need.", "confirm": "Logi välja", "lost": "Need kirjad jäid saatmata ja kustutati väljalogimisel: {subjects}" + }, + "errors": { + "unavailable": "Selleks on vaja ühendust. Proovi uuesti, kui oled võrgus.", + "plugins_down": "Sinu allkirjastamise või krüptimise pistikprogramm ei tööta võrguta. Saada see, kui oled jälle võrgus.", + "file_missing": "Võrguta lisatud faili pole enam selles seadmes. Lisa see uuesti.", + "no_sent_mailbox": "Kausta Saadetud ei leitud.", + "no_drafts_mailbox": "Kausta Mustandid ei leitud." } }, "deep_link": { diff --git a/locales/fa/common.json b/locales/fa/common.json index f47882b00..d5604b546 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5667,11 +5667,15 @@ "delete": "حذف", "delete_confirm_title": "این پیام حذف شود؟", "delete_confirm_message": "ارسال نشده است و قابل بازگرداندن نیست.", - "changes": "{count, plural, one {# تغییر} other {# تغییر}} در خوانده‌شدن، پرچم‌ها یا پوشه‌ها وقتی دوباره آنلاین شوید ارسال می‌شود." + "changes": "{count, plural, one {# تغییر} other {# تغییر}} در خوانده‌شدن، پرچم‌ها یا پوشه‌ها وقتی دوباره آنلاین شوید ارسال می‌شود.", + "status_held": "قوانین سازمان شما در مدت انتظار تغییر کرد. برای ارسال، آن را دوباره بنویسید.", + "retry_label": "ارسال دوباره: {subject}", + "delete_label": "حذف: {subject}" }, "banner": { "offline": "آفلاین هستید. ایمیل‌های نگه‌داشته‌شده روی این دستگاه را می‌بینید. آنچه تغییر می‌دهید یا می‌نویسید وقتی دوباره آنلاین شوید ارسال می‌شود.", - "outbox": "{count, plural, one {# پیام} other {# پیام}} در صندوق خروجی" + "outbox": "{count, plural, one {# پیام} other {# پیام}} در صندوق خروجی", + "server_down": "دسترسی به سرور ایمیل ممکن نیست. ایمیل‌های نگه‌داشته‌شده روی این دستگاه را می‌بینید. آنچه تغییر می‌دهید یا می‌نویسید وقتی سرور دوباره پاسخ دهد ارسال می‌شود." }, "toast": { "queued": "در صندوق خروجی ذخیره شد. وقتی دوباره آنلاین شوید ارسال می‌شود.", @@ -5681,13 +5685,22 @@ "refused_other": "این کار به اتصال نیاز دارد. وقتی آنلاین شدید دوباره امتحان کنید.", "forbidden": "سازمان شما حالت آفلاین را خاموش کرد. ایمیل‌های نگه‌داشته‌شده روی این دستگاه پاک شدند.", "back_online": "دوباره آنلاین هستید. برای ورود و ارسال موارد منتظر، صفحه را دوباره بارگیری کنید.", - "reload": "بارگیری دوباره" + "reload": "بارگیری دوباره", + "held": "یک پیام در صندوق خروجی به شما نیاز دارد: {subject}", + "lost": "این پیام‌ها ارسال نشدند و پاک شدند: {subjects}" }, "signout": { "title": "با پیام‌های ارسال‌نشده خارج می‌شوید؟", "message": "{count, plural, one {# پیام ارسال نشده است.} other {# پیام ارسال نشده‌اند.}} خروج آن‌ها را پاک می‌کند.", "confirm": "خروج", "lost": "این پیام‌ها ارسال نشدند و هنگام خروج شما پاک شدند: {subjects}" + }, + "errors": { + "unavailable": "این کار به اتصال نیاز دارد. وقتی آنلاین شدید دوباره امتحان کنید.", + "plugins_down": "افزونهٔ امضا یا رمزگذاری شما در حالت آفلاین کار نمی‌کند. وقتی دوباره آنلاین شدید این را ارسال کنید.", + "file_missing": "فایلی که آفلاین پیوست شد دیگر روی این دستگاه نیست. دوباره پیوستش کنید.", + "no_sent_mailbox": "پوشهٔ ارسال‌شده پیدا نشد.", + "no_drafts_mailbox": "پوشهٔ پیش‌نویس‌ها پیدا نشد." } }, "deep_link": { diff --git a/locales/fr/common.json b/locales/fr/common.json index d8521e62c..f0fffeac3 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5667,11 +5667,15 @@ "delete": "Supprimer", "delete_confirm_title": "Supprimer ce message ?", "delete_confirm_message": "Il n'a pas été envoyé et ne pourra pas être récupéré.", - "changes": "{count, plural, one {# modification} other {# modifications}} (lu, indicateurs ou dossiers) seront envoyées dès que vous serez en ligne." + "changes": "{count, plural, one {# modification} other {# modifications}} (lu, indicateurs ou dossiers) seront envoyées dès que vous serez en ligne.", + "status_held": "Les règles de votre organisation ont changé pendant l'attente. Écrivez-le à nouveau pour l'envoyer.", + "retry_label": "Renvoyer : {subject}", + "delete_label": "Supprimer : {subject}" }, "banner": { "offline": "Vous êtes hors ligne. Vous voyez les e-mails gardés sur cet appareil. Ce que vous modifiez ou écrivez est envoyé dès que vous êtes de nouveau en ligne.", - "outbox": "{count, plural, one {# message} other {# messages}} dans la boîte d'envoi" + "outbox": "{count, plural, one {# message} other {# messages}} dans la boîte d'envoi", + "server_down": "Le serveur de messagerie est injoignable. Vous voyez les e-mails gardés sur cet appareil. Ce que vous modifiez ou écrivez est envoyé dès qu'il répond de nouveau." }, "toast": { "queued": "Enregistré dans la boîte d'envoi. Il sera envoyé dès que vous serez en ligne.", @@ -5681,13 +5685,22 @@ "refused_other": "Cette action demande une connexion. Réessayez quand vous serez en ligne.", "forbidden": "Votre organisation a désactivé le mode hors ligne. Les e-mails gardés sur cet appareil ont été supprimés.", "back_online": "Vous êtes de nouveau en ligne. Rechargez pour vous connecter et envoyer ce qui attend.", - "reload": "Recharger" + "reload": "Recharger", + "held": "Un message de la boîte d'envoi a besoin de vous : {subject}", + "lost": "Ces messages n'ont pas été envoyés et ont été supprimés : {subjects}" }, "signout": { "title": "Se déconnecter avec des messages non envoyés ?", "message": "{count, plural, one {# message n'a pas été envoyé.} other {# messages n'ont pas été envoyés.}} La déconnexion les supprime.", "confirm": "Se déconnecter", "lost": "Ces messages n'ont pas été envoyés et ont été supprimés lors de votre déconnexion : {subjects}" + }, + "errors": { + "unavailable": "Cette action demande une connexion. Réessayez quand vous serez en ligne.", + "plugins_down": "Votre plugin de signature ou de chiffrement ne fonctionne pas hors ligne. Envoyez ce message quand vous serez de nouveau en ligne.", + "file_missing": "Un fichier joint hors ligne n'est plus sur cet appareil. Joignez-le à nouveau.", + "no_sent_mailbox": "Votre dossier Envoyés est introuvable.", + "no_drafts_mailbox": "Votre dossier Brouillons est introuvable." } }, "deep_link": { diff --git a/locales/he/common.json b/locales/he/common.json index 9b45ac94d..fbe112392 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5668,11 +5668,15 @@ "delete": "מחיקה", "delete_confirm_title": "למחוק את ההודעה?", "delete_confirm_message": "היא לא נשלחה ואי אפשר לשחזר אותה.", - "changes": "{count, plural, one {שינוי אחד} other {# שינויים}} בסטטוס קריאה, בסימונים או בתיקיות יישלחו כשתהיה שוב מחובר." + "changes": "{count, plural, one {שינוי אחד} other {# שינויים}} בסטטוס קריאה, בסימונים או בתיקיות יישלחו כשתהיה שוב מחובר.", + "status_held": "כללי הארגון השתנו בזמן שההודעה המתינה. יש לכתוב אותה שוב כדי לשלוח.", + "retry_label": "שליחה חוזרת: {subject}", + "delete_label": "מחיקה: {subject}" }, "banner": { "offline": "אין חיבור. מוצג הדואר השמור במכשיר הזה. מה שמשנים או כותבים יישלח כשיהיה שוב חיבור.", - "outbox": "{count, plural, one {הודעה אחת} other {# הודעות}} בתיבת הדואר היוצא" + "outbox": "{count, plural, one {הודעה אחת} other {# הודעות}} בתיבת הדואר היוצא", + "server_down": "אין גישה לשרת הדואר. מוצג הדואר השמור במכשיר הזה. מה שמשנים או כותבים יישלח כשהשרת יענה שוב." }, "toast": { "queued": "נשמרה בתיבת הדואר היוצא. היא תישלח כשיהיה שוב חיבור.", @@ -5682,13 +5686,22 @@ "refused_other": "הפעולה הזו דורשת חיבור. אפשר לנסות שוב כשיהיה חיבור.", "forbidden": "הארגון שלך כיבה את המצב הלא מקוון. הדואר שנשמר במכשיר הזה הוסר.", "back_online": "יש שוב חיבור. יש לטעון מחדש כדי להתחבר ולשלוח את מה שממתין.", - "reload": "טעינה מחדש" + "reload": "טעינה מחדש", + "held": "הודעה בתיבת הדואר היוצא דורשת את תשומת לבך: {subject}", + "lost": "ההודעות האלה לא נשלחו ונמחקו: {subjects}" }, "signout": { "title": "להתנתק עם הודעות שלא נשלחו?", "message": "{count, plural, one {הודעה אחת לא נשלחה.} other {# הודעות לא נשלחו.}} ההתנתקות מוחקת אותן.", "confirm": "התנתקות", "lost": "ההודעות האלה לא נשלחו ונמחקו בזמן ההתנתקות: {subjects}" + }, + "errors": { + "unavailable": "הפעולה הזו דורשת חיבור. אפשר לנסות שוב כשיהיה חיבור.", + "plugins_down": "תוסף החתימה או ההצפנה שלך לא פועל במצב לא מקוון. יש לשלוח זאת כשיהיה שוב חיבור.", + "file_missing": "קובץ שצורף במצב לא מקוון כבר לא נמצא במכשיר. יש לצרף אותו שוב.", + "no_sent_mailbox": "התיקייה 'נשלחו' לא נמצאה.", + "no_drafts_mailbox": "התיקייה 'טיוטות' לא נמצאה." } }, "deep_link": { diff --git a/locales/hu/common.json b/locales/hu/common.json index 0f3ec51ea..2f8963a78 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5667,11 +5667,15 @@ "delete": "Törlés", "delete_confirm_title": "Törli ezt a levelet?", "delete_confirm_message": "Nem lett elküldve, és nem állítható vissza.", - "changes": "{count, plural, one {# módosítás} other {# módosítás}} (olvasottság, jelölők, mappák) elmegy, amikor újra online lesz." + "changes": "{count, plural, one {# módosítás} other {# módosítás}} (olvasottság, jelölők, mappák) elmegy, amikor újra online lesz.", + "status_held": "A szervezete szabályai megváltoztak, amíg a levél várt. Írja meg újra az elküldéshez.", + "retry_label": "Újraküldés: {subject}", + "delete_label": "Törlés: {subject}" }, "banner": { "offline": "Offline állapotban van. Az ezen az eszközön tárolt leveleket látja. Amit módosít vagy ír, elmegy, amikor újra online lesz.", - "outbox": "{count, plural, one {# levél} other {# levél}} a postázandók között" + "outbox": "{count, plural, one {# levél} other {# levél}} a postázandók között", + "server_down": "A levelezőkiszolgáló nem érhető el. Az ezen az eszközön tárolt leveleket látja. Amit módosít vagy ír, elmegy, amikor újra válaszol." }, "toast": { "queued": "Mentve a postázandók közé. Akkor megy el, amikor újra online lesz.", @@ -5681,13 +5685,22 @@ "refused_other": "Ehhez kapcsolat kell. Próbálja újra, amikor online lesz.", "forbidden": "A szervezete kikapcsolta az offline módot. Az eszközön tárolt leveleket eltávolítottuk.", "back_online": "Újra online van. Töltse újra az oldalt a bejelentkezéshez és a váró levelek elküldéséhez.", - "reload": "Újratöltés" + "reload": "Újratöltés", + "held": "Egy postázandó levél figyelmet igényel: {subject}", + "lost": "Ezek a levelek nem mentek el, és törlődtek: {subjects}" }, "signout": { "title": "Kijelentkezik elküldetlen levelekkel?", "message": "{count, plural, one {# levél nincs elküldve.} other {# levél nincs elküldve.}} A kijelentkezés törli őket.", "confirm": "Kijelentkezés", "lost": "Ezek a levelek nem mentek el, és a kijelentkezéskor törlődtek: {subjects}" + }, + "errors": { + "unavailable": "Ehhez kapcsolat kell. Próbálja újra, amikor online lesz.", + "plugins_down": "Az aláíró vagy titkosító bővítménye offline nem fut. Küldje el, amikor újra online lesz.", + "file_missing": "Egy offline csatolt fájl már nincs ezen az eszközön. Csatolja újra.", + "no_sent_mailbox": "Az Elküldött mappa nem található.", + "no_drafts_mailbox": "A Piszkozatok mappa nem található." } }, "deep_link": { diff --git a/locales/it/common.json b/locales/it/common.json index bddd37f97..2ce841e24 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5667,11 +5667,15 @@ "delete": "Elimina", "delete_confirm_title": "Eliminare questo messaggio?", "delete_confirm_message": "Non è stato inviato e non si potrà recuperare.", - "changes": "{count, plural, one {# modifica} other {# modifiche}} a letti, contrassegni o cartelle verranno inviate quando sarai di nuovo online." + "changes": "{count, plural, one {# modifica} other {# modifiche}} a letti, contrassegni o cartelle verranno inviate quando sarai di nuovo online.", + "status_held": "Le regole della tua organizzazione sono cambiate mentre il messaggio aspettava. Scrivilo di nuovo per inviarlo.", + "retry_label": "Invia di nuovo: {subject}", + "delete_label": "Elimina: {subject}" }, "banner": { "offline": "Sei offline. Vedi la posta conservata su questo dispositivo. Ciò che modifichi o scrivi viene inviato quando torni online.", - "outbox": "{count, plural, one {# messaggio} other {# messaggi}} nella posta in uscita" + "outbox": "{count, plural, one {# messaggio} other {# messaggi}} nella posta in uscita", + "server_down": "Il server di posta non è raggiungibile. Vedi la posta conservata su questo dispositivo. Ciò che modifichi o scrivi viene inviato quando risponde di nuovo." }, "toast": { "queued": "Salvato nella posta in uscita. Verrà inviato quando sarai di nuovo online.", @@ -5681,13 +5685,22 @@ "refused_other": "Serve una connessione. Riprova quando sei online.", "forbidden": "La tua organizzazione ha disattivato la modalità offline. La posta conservata su questo dispositivo è stata rimossa.", "back_online": "Sei di nuovo online. Ricarica per accedere e inviare ciò che è in attesa.", - "reload": "Ricarica" + "reload": "Ricarica", + "held": "Un messaggio nella posta in uscita richiede la tua attenzione: {subject}", + "lost": "Questi messaggi non sono stati inviati e sono stati eliminati: {subjects}" }, "signout": { "title": "Uscire con messaggi non inviati?", "message": "{count, plural, one {# messaggio non è stato inviato.} other {# messaggi non sono stati inviati.}} Uscendo vengono eliminati.", "confirm": "Esci", "lost": "Questi messaggi non sono stati inviati e sono stati eliminati quando sei uscito: {subjects}" + }, + "errors": { + "unavailable": "Serve una connessione. Riprova quando sei online.", + "plugins_down": "Il tuo plugin di firma o cifratura non funziona offline. Invia questo messaggio quando sarai di nuovo online.", + "file_missing": "Un file allegato offline non è più su questo dispositivo. Allegalo di nuovo.", + "no_sent_mailbox": "Impossibile trovare la cartella Inviati.", + "no_drafts_mailbox": "Impossibile trovare la cartella Bozze." } }, "deep_link": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 6c74e822f..cd0d48121 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5667,11 +5667,15 @@ "delete": "削除", "delete_confirm_title": "このメッセージを削除しますか?", "delete_confirm_message": "送信されておらず、元に戻せません。", - "changes": "{count, plural, other {既読、フラグ、フォルダーの変更 # 件}}は、再びオンラインになると送信されます。" + "changes": "{count, plural, other {既読、フラグ、フォルダーの変更 # 件}}は、再びオンラインになると送信されます。", + "status_held": "待機中に組織のルールが変わりました。送信するにはもう一度作成してください。", + "retry_label": "もう一度送信: {subject}", + "delete_label": "削除: {subject}" }, "banner": { "offline": "オフラインです。このデバイスに保存したメールを表示しています。変更や作成した内容は、再びオンラインになると送信されます。", - "outbox": "{count, plural, other {送信トレイに # 件}}" + "outbox": "{count, plural, other {送信トレイに # 件}}", + "server_down": "メールサーバーに接続できません。このデバイスに保存したメールを表示しています。変更や作成した内容は、サーバーが応答すると送信されます。" }, "toast": { "queued": "送信トレイに保存しました。再びオンラインになると送信されます。", @@ -5681,13 +5685,22 @@ "refused_other": "この操作には接続が必要です。オンラインになってからもう一度お試しください。", "forbidden": "組織によってオフラインモードがオフになりました。このデバイスに保存したメールは削除されました。", "back_online": "再びオンラインになりました。再読み込みしてサインインし、待機中のものを送信してください。", - "reload": "再読み込み" + "reload": "再読み込み", + "held": "送信トレイのメッセージに対応が必要です: {subject}", + "lost": "次のメッセージは送信されず削除されました: {subjects}" }, "signout": { "title": "未送信のメッセージがある状態でサインアウトしますか?", "message": "{count, plural, other {# 件のメッセージが未送信です。}}サインアウトすると削除されます。", "confirm": "サインアウト", "lost": "次のメッセージは送信されず、サインアウト時に削除されました: {subjects}" + }, + "errors": { + "unavailable": "この操作には接続が必要です。オンラインになってからもう一度お試しください。", + "plugins_down": "署名または暗号化のプラグインはオフラインでは動作していません。再びオンラインになってから送信してください。", + "file_missing": "オフラインで添付したファイルがこのデバイスにありません。もう一度添付してください。", + "no_sent_mailbox": "送信済みフォルダーが見つかりません。", + "no_drafts_mailbox": "下書きフォルダーが見つかりません。" } }, "deep_link": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 80ce65ad3..714222b11 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5667,11 +5667,15 @@ "delete": "삭제", "delete_confirm_title": "이 메시지를 삭제하시겠습니까?", "delete_confirm_message": "전송되지 않았으며 되돌릴 수 없습니다.", - "changes": "읽음, 플래그, 폴더 {count, plural, other {변경 #건}}은 다시 온라인이 되면 전송됩니다." + "changes": "읽음, 플래그, 폴더 {count, plural, other {변경 #건}}은 다시 온라인이 되면 전송됩니다.", + "status_held": "메시지가 대기하는 동안 조직 규칙이 바뀌었습니다. 보내려면 다시 작성하세요.", + "retry_label": "다시 보내기: {subject}", + "delete_label": "삭제: {subject}" }, "banner": { "offline": "오프라인 상태입니다. 이 기기에 보관한 메일이 표시됩니다. 변경하거나 작성한 내용은 다시 온라인이 되면 전송됩니다.", - "outbox": "보낼편지함에 {count, plural, other {#개}}" + "outbox": "보낼편지함에 {count, plural, other {#개}}", + "server_down": "메일 서버에 연결할 수 없습니다. 이 기기에 보관한 메일이 표시됩니다. 변경하거나 작성한 내용은 서버가 다시 응답하면 전송됩니다." }, "toast": { "queued": "보낼편지함에 저장했습니다. 다시 온라인이 되면 전송됩니다.", @@ -5681,13 +5685,22 @@ "refused_other": "이 작업에는 연결이 필요합니다. 온라인이 되면 다시 시도하세요.", "forbidden": "조직에서 오프라인 모드를 껐습니다. 이 기기에 보관한 메일이 삭제되었습니다.", "back_online": "다시 온라인입니다. 새로고침해서 로그인하고 대기 중인 항목을 보내세요.", - "reload": "새로고침" + "reload": "새로고침", + "held": "보낼편지함의 메시지에 확인이 필요합니다: {subject}", + "lost": "다음 메시지는 전송되지 않고 삭제되었습니다: {subjects}" }, "signout": { "title": "보내지 않은 메시지가 있는데 로그아웃하시겠습니까?", "message": "{count, plural, other {메시지 #개가 전송되지 않았습니다.}} 로그아웃하면 삭제됩니다.", "confirm": "로그아웃", "lost": "다음 메시지는 전송되지 않았으며 로그아웃 시 삭제되었습니다: {subjects}" + }, + "errors": { + "unavailable": "이 작업에는 연결이 필요합니다. 온라인이 되면 다시 시도하세요.", + "plugins_down": "서명 또는 암호화 플러그인이 오프라인에서 실행되지 않습니다. 다시 온라인이 되면 보내세요.", + "file_missing": "오프라인에서 첨부한 파일이 이 기기에 더 이상 없습니다. 다시 첨부하세요.", + "no_sent_mailbox": "보낸편지함 폴더를 찾을 수 없습니다.", + "no_drafts_mailbox": "임시보관함 폴더를 찾을 수 없습니다." } }, "deep_link": { diff --git a/locales/lv/common.json b/locales/lv/common.json index 3cf6f01d9..f0467102f 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5667,11 +5667,15 @@ "delete": "Dzēst", "delete_confirm_title": "Dzēst šo vēstuli?", "delete_confirm_message": "Tā nav nosūtīta, un to nevar atgūt.", - "changes": "{count, plural, zero {# izmaiņas} one {# izmaiņa} other {# izmaiņas}} lasīšanas statusā, atzīmēs vai mapēs tiks nosūtītas, kad atkal būsiet tiešsaistē." + "changes": "{count, plural, zero {# izmaiņas} one {# izmaiņa} other {# izmaiņas}} lasīšanas statusā, atzīmēs vai mapēs tiks nosūtītas, kad atkal būsiet tiešsaistē.", + "status_held": "Jūsu organizācijas noteikumi mainījās, kamēr vēstule gaidīja. Uzrakstiet to vēlreiz, lai nosūtītu.", + "retry_label": "Sūtīt vēlreiz: {subject}", + "delete_label": "Dzēst: {subject}" }, "banner": { "offline": "Jūs esat bezsaistē. Redzat šajā ierīcē glabāto e-pastu. Ko maināt vai rakstāt, tiek nosūtīts, kad atkal būsiet tiešsaistē.", - "outbox": "{count, plural, zero {# vēstules} one {# vēstule} other {# vēstules}} izsūtnē" + "outbox": "{count, plural, zero {# vēstules} one {# vēstule} other {# vēstules}} izsūtnē", + "server_down": "E-pasta serveris nav sasniedzams. Redzat šajā ierīcē glabāto e-pastu. Ko maināt vai rakstāt, tiek nosūtīts, kad tas atkal atbild." }, "toast": { "queued": "Saglabāta izsūtnē. Tā tiks nosūtīta, kad atkal būsiet tiešsaistē.", @@ -5681,13 +5685,22 @@ "refused_other": "Tam vajag savienojumu. Mēģiniet vēlreiz, kad būsiet tiešsaistē.", "forbidden": "Jūsu organizācija izslēdza bezsaistes režīmu. Šajā ierīcē glabātais e-pasts tika noņemts.", "back_online": "Jūs atkal esat tiešsaistē. Pārlādējiet, lai pierakstītos un nosūtītu gaidošo.", - "reload": "Pārlādēt" + "reload": "Pārlādēt", + "held": "Vēstulei izsūtnē nepieciešama jūsu uzmanība: {subject}", + "lost": "Šīs vēstules netika nosūtītas un tika izdzēstas: {subjects}" }, "signout": { "title": "Izrakstīties ar nenosūtītām vēstulēm?", "message": "{count, plural, zero {# vēstules nav nosūtītas.} one {# vēstule nav nosūtīta.} other {# vēstules nav nosūtītas.}} Izrakstoties tās tiek izdzēstas.", "confirm": "Izrakstīties", "lost": "Šīs vēstules netika nosūtītas un tika izdzēstas, kad jūs izrakstījāt: {subjects}" + }, + "errors": { + "unavailable": "Tam vajag savienojumu. Mēģiniet vēlreiz, kad būsiet tiešsaistē.", + "plugins_down": "Jūsu parakstīšanas vai šifrēšanas spraudnis bezsaistē nedarbojas. Nosūtiet to, kad atkal būsiet tiešsaistē.", + "file_missing": "Bezsaistē pievienotā datne vairs nav šajā ierīcē. Pievienojiet to vēlreiz.", + "no_sent_mailbox": "Mape Nosūtītās netika atrasta.", + "no_drafts_mailbox": "Mape Melnraksti netika atrasta." } }, "deep_link": { diff --git a/locales/mn/common.json b/locales/mn/common.json index d88512039..7e578a0f4 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5667,11 +5667,15 @@ "delete": "Устгах", "delete_confirm_title": "Энэ мессежийг устгах уу?", "delete_confirm_message": "Илгээгдээгүй бөгөөд сэргээх боломжгүй.", - "changes": "Уншсан, тэмдэглэгээ эсвэл хавтасны {count, plural, one {# өөрчлөлт} other {# өөрчлөлт}} таныг дахин онлайн болоход илгээгдэнэ." + "changes": "Уншсан, тэмдэглэгээ эсвэл хавтасны {count, plural, one {# өөрчлөлт} other {# өөрчлөлт}} таныг дахин онлайн болоход илгээгдэнэ.", + "status_held": "Мессеж хүлээж байх хооронд байгууллагын дүрэм өөрчлөгдсөн. Илгээхийн тулд дахин бичнэ үү.", + "retry_label": "Дахин илгээх: {subject}", + "delete_label": "Устгах: {subject}" }, "banner": { "offline": "Та офлайн байна. Энэ төхөөрөмж дээр хадгалсан имэйлийг харж байна. Өөрчилсөн эсвэл бичсэн зүйл тань дахин онлайн болоход илгээгдэнэ.", - "outbox": "Гарах хайрцагт {count, plural, one {# мессеж} other {# мессеж}}" + "outbox": "Гарах хайрцагт {count, plural, one {# мессеж} other {# мессеж}}", + "server_down": "Имэйл сервертэй холбогдох боломжгүй байна. Энэ төхөөрөмж дээр хадгалсан имэйлийг харж байна. Өөрчилсөн эсвэл бичсэн зүйл тань сервер дахин хариулахад илгээгдэнэ." }, "toast": { "queued": "Гарах хайрцагт хадгаллаа. Таныг дахин онлайн болоход илгээгдэнэ.", @@ -5681,13 +5685,22 @@ "refused_other": "Үүнд холболт хэрэгтэй. Онлайн болсон үедээ дахин оролдоно уу.", "forbidden": "Таны байгууллага офлайн горимыг унтраасан. Энэ төхөөрөмж дээр хадгалсан имэйлийг устгалаа.", "back_online": "Та дахин онлайн боллоо. Нэвтэрч, хүлээгдэж буйг илгээхийн тулд дахин ачаална уу.", - "reload": "Дахин ачаалах" + "reload": "Дахин ачаалах", + "held": "Гарах хайрцаг дахь мессеж таны анхаарлыг хүлээж байна: {subject}", + "lost": "Эдгээр мессеж илгээгдээгүй бөгөөд устгагдсан: {subjects}" }, "signout": { "title": "Илгээгдээгүй мессежтэй гарах уу?", "message": "{count, plural, one {# мессеж илгээгдээгүй байна.} other {# мессеж илгээгдээгүй байна.}} Гарвал тэдгээр устна.", "confirm": "Гарах", "lost": "Эдгээр мессеж илгээгдээгүй бөгөөд таныг гарах үед устгагдсан: {subjects}" + }, + "errors": { + "unavailable": "Үүнд холболт хэрэгтэй. Онлайн болсон үедээ дахин оролдоно уу.", + "plugins_down": "Таны гарын үсэг эсвэл шифрлэлтийн плагин офлайн үед ажиллахгүй байна. Дахин онлайн болоход үүнийг илгээгээрэй.", + "file_missing": "Офлайн үед хавсаргасан файл энэ төхөөрөмж дээр байхгүй болсон. Дахин хавсаргана уу.", + "no_sent_mailbox": "Илгээсэн хавтас олдсонгүй.", + "no_drafts_mailbox": "Ноорог хавтас олдсонгүй." } }, "deep_link": { diff --git a/locales/nb/common.json b/locales/nb/common.json index 937dacf83..29dbdb9d7 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5667,11 +5667,15 @@ "delete": "Slett", "delete_confirm_title": "Slette denne meldingen?", "delete_confirm_message": "Den er ikke sendt og kan ikke hentes tilbake.", - "changes": "{count, plural, one {# endring} other {# endringer}} av lest, flagg eller mapper sendes når du er tilkoblet igjen." + "changes": "{count, plural, one {# endring} other {# endringer}} av lest, flagg eller mapper sendes når du er tilkoblet igjen.", + "status_held": "Organisasjonens regler endret seg mens meldingen ventet. Skriv den på nytt for å sende den.", + "retry_label": "Send igjen: {subject}", + "delete_label": "Slett: {subject}" }, "banner": { "offline": "Du er frakoblet. Du ser e-posten som er lagret på denne enheten. Det du endrer eller skriver, sendes når du er tilkoblet igjen.", - "outbox": "{count, plural, one {# melding} other {# meldinger}} i utboksen" + "outbox": "{count, plural, one {# melding} other {# meldinger}} i utboksen", + "server_down": "E-postserveren kan ikke nås. Du ser e-posten som er lagret på denne enheten. Det du endrer eller skriver, sendes når den svarer igjen." }, "toast": { "queued": "Lagret i utboksen. Den sendes når du er tilkoblet igjen.", @@ -5681,13 +5685,22 @@ "refused_other": "Dette krever tilkobling. Prøv igjen når du er tilkoblet.", "forbidden": "Organisasjonen din har slått av frakoblet modus. E-posten som var lagret på denne enheten, er fjernet.", "back_online": "Du er tilkoblet igjen. Last inn på nytt for å logge på og sende det som venter.", - "reload": "Last inn på nytt" + "reload": "Last inn på nytt", + "held": "En melding i utboksen trenger deg: {subject}", + "lost": "Disse meldingene ble ikke sendt og er slettet: {subjects}" }, "signout": { "title": "Logge av med usendte meldinger?", "message": "{count, plural, one {# melding er ikke sendt.} other {# meldinger er ikke sendt.}} Når du logger av, slettes de.", "confirm": "Logg av", "lost": "Disse meldingene ble ikke sendt og ble slettet da du ble logget av: {subjects}" + }, + "errors": { + "unavailable": "Dette krever tilkobling. Prøv igjen når du er tilkoblet.", + "plugins_down": "Programtillegget for signering eller kryptering kjører ikke uten tilkobling. Send dette når du er tilkoblet igjen.", + "file_missing": "En fil som ble lagt ved uten tilkobling, er ikke lenger på denne enheten. Legg den ved på nytt.", + "no_sent_mailbox": "Fant ikke mappen Sendt.", + "no_drafts_mailbox": "Fant ikke mappen Utkast." } }, "deep_link": { diff --git a/locales/nl/common.json b/locales/nl/common.json index cc40e94b4..8b7784da5 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5642,7 +5642,7 @@ "title": "Offline", "description": "E-mail op dit apparaat lezen en schrijven zonder verbinding.", "keep_label": "E-mail op dit apparaat bewaren", - "keep_description": "Bewaart de laatste 30 dagen van je inbox en van de mappen die je opent, tot 250 MB, versleuteld in deze browser. Offline kun je e-mail lezen, markeren en verplaatsen, en wat je schrijft wacht in de outbox. Afmelden verwijdert alles.", + "keep_description": "Bewaart de laatste 30 dagen van je inbox en van de mappen die je opent, tot 250 MB, versleuteld in deze browser. Offline kun je e-mail lezen, markeren en verplaatsen, en wat je schrijft wacht in Postvak UIT. Afmelden verwijdert alles.", "warning": "Iedereen die dit browserprofiel kan gebruiken, kan deze e-mail lezen. Zet dit alleen aan op je eigen apparaat en gebruik schijfversleuteling.", "forbidden": "Je organisatie staat niet toe dat e-mail op apparaten wordt bewaard.", "unsupported": "Deze browser kan geen e-mail bewaren voor offline gebruik.", @@ -5650,11 +5650,11 @@ "usage": "{size} op dit apparaat", "usage_updated": "{size} op dit apparaat, bijgewerkt {time}", "off_confirm_title": "Offlinemodus uitzetten?", - "off_confirm_message": "{count, plural, one {# bericht in de outbox is nog niet verzonden.} other {# berichten in de outbox zijn nog niet verzonden.}} Uitzetten verwijdert ze, samen met de e-mail die op dit apparaat is bewaard.", + "off_confirm_message": "{count, plural, one {# bericht in Postvak UIT is nog niet verzonden.} other {# berichten in Postvak UIT zijn nog niet verzonden.}} Uitzetten verwijdert ze, samen met de e-mail die op dit apparaat is bewaard.", "off_confirm": "Uitzetten" }, "outbox": { - "title": "Outbox", + "title": "Postvak UIT", "description": "Berichten die je offline hebt geschreven. Ze worden verzonden zodra dit apparaat weer online is.", "empty": "Er wacht niets op verzending.", "no_subject": "(geen onderwerp)", @@ -5667,27 +5667,40 @@ "delete": "Verwijderen", "delete_confirm_title": "Dit bericht verwijderen?", "delete_confirm_message": "Het is niet verzonden en kan niet worden teruggehaald.", - "changes": "{count, plural, one {# wijziging} other {# wijzigingen}} aan gelezen, markeringen of mappen worden verzonden zodra je weer online bent." + "changes": "{count, plural, one {# wijziging} other {# wijzigingen}} aan gelezen, markeringen of mappen worden verzonden zodra je weer online bent.", + "status_held": "De regels van je organisatie zijn veranderd terwijl dit bericht wachtte. Schrijf het opnieuw om het te verzenden.", + "retry_label": "Opnieuw verzenden: {subject}", + "delete_label": "Verwijderen: {subject}" }, "banner": { "offline": "Je bent offline. Je ziet de e-mail die op dit apparaat is bewaard. Wat je wijzigt of schrijft, wordt verzonden zodra je weer online bent.", - "outbox": "{count, plural, one {# bericht} other {# berichten}} in de outbox" + "outbox": "{count, plural, one {# bericht} other {# berichten}} in Postvak UIT", + "server_down": "De mailserver is niet bereikbaar. Je ziet de e-mail die op dit apparaat is bewaard. Wat je wijzigt of schrijft, wordt verzonden zodra hij weer antwoordt." }, "toast": { - "queued": "Opgeslagen in de outbox. Het wordt verzonden zodra je weer online bent.", - "sent": "Verzonden uit de outbox: {subject}", - "send_failed": "Een bericht in de outbox kon niet worden verzonden: {subject}", + "queued": "Opgeslagen in Postvak UIT. Het wordt verzonden zodra je weer online bent.", + "sent": "Verzonden uit Postvak UIT: {subject}", + "send_failed": "Een bericht in Postvak UIT kon niet worden verzonden: {subject}", "refused_delete": "Definitief verwijderen kan alleen met een verbinding. Probeer het opnieuw als je online bent.", "refused_other": "Hiervoor is een verbinding nodig. Probeer het opnieuw als je online bent.", "forbidden": "Je organisatie heeft de offlinemodus uitgezet. De e-mail op dit apparaat is verwijderd.", "back_online": "Je bent weer online. Laad opnieuw om je aan te melden en te verzenden wat wacht.", - "reload": "Opnieuw laden" + "reload": "Opnieuw laden", + "held": "Een bericht in Postvak UIT heeft je nodig: {subject}", + "lost": "Deze berichten zijn niet verzonden en zijn verwijderd: {subjects}" }, "signout": { "title": "Afmelden met onverzonden berichten?", "message": "{count, plural, one {# bericht is nog niet verzonden.} other {# berichten zijn nog niet verzonden.}} Afmelden verwijdert ze.", "confirm": "Afmelden", "lost": "Deze berichten zijn niet verzonden en zijn bij het afmelden verwijderd: {subjects}" + }, + "errors": { + "unavailable": "Hiervoor is een verbinding nodig. Probeer het opnieuw als je online bent.", + "plugins_down": "Je plug-in voor ondertekenen of versleutelen werkt niet offline. Verzend dit als je weer online bent.", + "file_missing": "Een bestand dat je offline hebt bijgevoegd, staat niet meer op dit apparaat. Voeg het opnieuw toe.", + "no_sent_mailbox": "Je map Verzonden is niet gevonden.", + "no_drafts_mailbox": "Je map Concepten is niet gevonden." } }, "deep_link": { diff --git a/locales/pl/common.json b/locales/pl/common.json index 471ed527c..2ab5fdc66 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5667,11 +5667,15 @@ "delete": "Usuń", "delete_confirm_title": "Usunąć tę wiadomość?", "delete_confirm_message": "Nie została wysłana i nie da się jej przywrócić.", - "changes": "{count, plural, one {# zmiana} few {# zmiany} many {# zmian} other {# zmiany}} przeczytania, flag lub folderów zostanie wysłana, gdy znów będziesz online." + "changes": "{count, plural, one {# zmiana} few {# zmiany} many {# zmian} other {# zmiany}} przeczytania, flag lub folderów zostanie wysłana, gdy znów będziesz online.", + "status_held": "Zasady Twojej organizacji zmieniły się, gdy wiadomość czekała. Napisz ją ponownie, aby ją wysłać.", + "retry_label": "Wyślij ponownie: {subject}", + "delete_label": "Usuń: {subject}" }, "banner": { "offline": "Jesteś offline. Widzisz pocztę przechowywaną na tym urządzeniu. To, co zmienisz lub napiszesz, zostanie wysłane, gdy znów będziesz online.", - "outbox": "{count, plural, one {# wiadomość} few {# wiadomości} many {# wiadomości} other {# wiadomości}} w skrzynce nadawczej" + "outbox": "{count, plural, one {# wiadomość} few {# wiadomości} many {# wiadomości} other {# wiadomości}} w skrzynce nadawczej", + "server_down": "Serwer poczty jest niedostępny. Widzisz pocztę przechowywaną na tym urządzeniu. To, co zmienisz lub napiszesz, zostanie wysłane, gdy znów odpowie." }, "toast": { "queued": "Zapisano w skrzynce nadawczej. Zostanie wysłana, gdy znów będziesz online.", @@ -5681,13 +5685,22 @@ "refused_other": "To wymaga połączenia. Spróbuj ponownie, gdy będziesz online.", "forbidden": "Twoja organizacja wyłączyła tryb offline. Poczta przechowywana na tym urządzeniu została usunięta.", "back_online": "Znów jesteś online. Odśwież, aby się zalogować i wysłać to, co czeka.", - "reload": "Odśwież" + "reload": "Odśwież", + "held": "Wiadomość w skrzynce nadawczej wymaga Twojej uwagi: {subject}", + "lost": "Te wiadomości nie zostały wysłane i zostały usunięte: {subjects}" }, "signout": { "title": "Wylogować się z niewysłanymi wiadomościami?", "message": "{count, plural, one {# wiadomość nie została wysłana.} few {# wiadomości nie zostały wysłane.} many {# wiadomości nie zostało wysłanych.} other {# wiadomości nie zostało wysłanych.}} Wylogowanie je usuwa.", "confirm": "Wyloguj się", "lost": "Te wiadomości nie zostały wysłane i zostały usunięte przy wylogowaniu: {subjects}" + }, + "errors": { + "unavailable": "To wymaga połączenia. Spróbuj ponownie, gdy będziesz online.", + "plugins_down": "Twoja wtyczka do podpisywania lub szyfrowania nie działa offline. Wyślij to, gdy znów będziesz online.", + "file_missing": "Plik dołączony offline nie znajduje się już na tym urządzeniu. Dołącz go ponownie.", + "no_sent_mailbox": "Nie znaleziono folderu Wysłane.", + "no_drafts_mailbox": "Nie znaleziono folderu Wersje robocze." } }, "deep_link": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 0a8abed0e..e0d754c1e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5667,11 +5667,15 @@ "delete": "Eliminar", "delete_confirm_title": "Eliminar esta mensagem?", "delete_confirm_message": "Não foi enviada e não pode ser recuperada.", - "changes": "{count, plural, one {# alteração} other {# alterações}} de lido, sinalizações ou pastas serão enviadas quando voltar a estar online." + "changes": "{count, plural, one {# alteração} other {# alterações}} de lido, sinalizações ou pastas serão enviadas quando voltar a estar online.", + "status_held": "As regras da sua organização mudaram enquanto a mensagem esperava. Escreva-a novamente para a enviar.", + "retry_label": "Enviar outra vez: {subject}", + "delete_label": "Eliminar: {subject}" }, "banner": { "offline": "Está offline. Vê o e-mail mantido neste dispositivo. O que alterar ou escrever é enviado quando voltar a estar online.", - "outbox": "{count, plural, one {# mensagem} other {# mensagens}} na caixa de saída" + "outbox": "{count, plural, one {# mensagem} other {# mensagens}} na caixa de saída", + "server_down": "Não é possível contactar o servidor de e-mail. Vê o e-mail mantido neste dispositivo. O que alterar ou escrever é enviado quando ele voltar a responder." }, "toast": { "queued": "Guardada na caixa de saída. É enviada quando voltar a estar online.", @@ -5681,13 +5685,22 @@ "refused_other": "Isto requer ligação. Tente novamente quando estiver online.", "forbidden": "A sua organização desativou o modo offline. O e-mail mantido neste dispositivo foi removido.", "back_online": "Está novamente online. Recarregue para iniciar sessão e enviar o que está à espera.", - "reload": "Recarregar" + "reload": "Recarregar", + "held": "Uma mensagem na caixa de saída precisa de si: {subject}", + "lost": "Estas mensagens não foram enviadas e foram eliminadas: {subjects}" }, "signout": { "title": "Terminar sessão com mensagens por enviar?", "message": "{count, plural, one {# mensagem não foi enviada.} other {# mensagens não foram enviadas.}} Terminar a sessão elimina-as.", "confirm": "Terminar sessão", "lost": "Estas mensagens não foram enviadas e foram eliminadas quando a sessão terminou: {subjects}" + }, + "errors": { + "unavailable": "Isto requer ligação. Tente novamente quando estiver online.", + "plugins_down": "O seu plugin de assinatura ou cifragem não funciona offline. Envie isto quando voltar a estar online.", + "file_missing": "Um ficheiro anexado offline já não está neste dispositivo. Anexe-o novamente.", + "no_sent_mailbox": "Não foi possível encontrar a pasta Enviados.", + "no_drafts_mailbox": "Não foi possível encontrar a pasta Rascunhos." } }, "deep_link": { diff --git a/locales/ro/common.json b/locales/ro/common.json index a48c370d8..8cd733b42 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5642,7 +5642,7 @@ "title": "Offline", "description": "Citiți și scrieți e-mailuri pe acest dispozitiv fără conexiune.", "keep_label": "Păstrează e-mailurile pe acest dispozitiv", - "keep_description": "Păstrează ultimele 30 de zile din Inbox și din dosarele pe care le deschideți, până la 250 MB, criptate în acest browser. Offline puteți citi, marca și muta e-mailuri, iar ce scrieți așteaptă în Outbox. La deconectare se șterge totul.", + "keep_description": "Păstrează ultimele 30 de zile din Inbox și din dosarele pe care le deschideți, până la 250 MB, criptate în acest browser. Offline puteți citi, marca și muta e-mailuri, iar ce scrieți așteaptă în Mesaje de trimis. La deconectare se șterge totul.", "warning": "Oricine poate folosi acest profil de browser poate citi aceste e-mailuri. Activați opțiunea doar pe propriul dispozitiv și folosiți criptarea discului.", "forbidden": "Organizația dvs. nu permite păstrarea e-mailurilor pe dispozitive.", "unsupported": "Acest browser nu poate păstra e-mailuri pentru folosire offline.", @@ -5650,11 +5650,11 @@ "usage": "{size} pe acest dispozitiv", "usage_updated": "{size} pe acest dispozitiv, actualizat {time}", "off_confirm_title": "Dezactivați modul offline?", - "off_confirm_message": "{count, plural, one {# mesaj din Outbox nu a fost trimis.} few {# mesaje din Outbox nu au fost trimise.} other {# de mesaje din Outbox nu au fost trimise.}} Dezactivarea le șterge, împreună cu e-mailurile păstrate pe acest dispozitiv.", + "off_confirm_message": "{count, plural, one {# mesaj din Mesaje de trimis nu a fost trimis.} few {# mesaje din Mesaje de trimis nu au fost trimise.} other {# de mesaje din Mesaje de trimis nu au fost trimise.}} Dezactivarea le șterge, împreună cu e-mailurile păstrate pe acest dispozitiv.", "off_confirm": "Dezactivează" }, "outbox": { - "title": "Outbox", + "title": "Mesaje de trimis", "description": "Mesajele scrise offline. Sunt trimise când dispozitivul este din nou online.", "empty": "Nimic nu așteaptă să fie trimis.", "no_subject": "(fără subiect)", @@ -5667,27 +5667,40 @@ "delete": "Șterge", "delete_confirm_title": "Ștergeți acest mesaj?", "delete_confirm_message": "Nu a fost trimis și nu poate fi recuperat.", - "changes": "{count, plural, one {# modificare} few {# modificări} other {# de modificări}} la citit, marcaje sau dosare vor fi trimise când sunteți din nou online." + "changes": "{count, plural, one {# modificare} few {# modificări} other {# de modificări}} la citit, marcaje sau dosare vor fi trimise când sunteți din nou online.", + "status_held": "Regulile organizației s-au schimbat cât timp mesajul a așteptat. Scrieți-l din nou pentru a-l trimite.", + "retry_label": "Trimite din nou: {subject}", + "delete_label": "Șterge: {subject}" }, "banner": { "offline": "Sunteți offline. Vedeți e-mailurile păstrate pe acest dispozitiv. Ce modificați sau scrieți este trimis când sunteți din nou online.", - "outbox": "{count, plural, one {# mesaj} few {# mesaje} other {# de mesaje}} în Outbox" + "outbox": "{count, plural, one {# mesaj} few {# mesaje} other {# de mesaje}} în Mesaje de trimis", + "server_down": "Serverul de e-mail nu poate fi contactat. Vedeți e-mailurile păstrate pe acest dispozitiv. Ce modificați sau scrieți este trimis când răspunde din nou." }, "toast": { - "queued": "Salvat în Outbox. Va fi trimis când sunteți din nou online.", - "sent": "Trimis din Outbox: {subject}", - "send_failed": "Un mesaj din Outbox nu a putut fi trimis: {subject}", + "queued": "Salvat în Mesaje de trimis. Va fi trimis când sunteți din nou online.", + "sent": "Trimis din Mesaje de trimis: {subject}", + "send_failed": "Un mesaj din Mesaje de trimis nu a putut fi trimis: {subject}", "refused_delete": "Ștergerea definitivă necesită o conexiune. Încercați din nou când sunteți online.", "refused_other": "Pentru asta este nevoie de conexiune. Încercați din nou când sunteți online.", "forbidden": "Organizația dvs. a dezactivat modul offline. E-mailurile păstrate pe acest dispozitiv au fost eliminate.", "back_online": "Sunteți din nou online. Reîncărcați pentru a vă conecta și a trimite ce așteaptă.", - "reload": "Reîncarcă" + "reload": "Reîncarcă", + "held": "Un mesaj din Mesaje de trimis are nevoie de dvs.: {subject}", + "lost": "Aceste mesaje nu au fost trimise și au fost șterse: {subjects}" }, "signout": { "title": "Vă deconectați cu mesaje netrimise?", "message": "{count, plural, one {# mesaj nu a fost trimis.} few {# mesaje nu au fost trimise.} other {# de mesaje nu au fost trimise.}} Deconectarea le șterge.", "confirm": "Deconectare", "lost": "Aceste mesaje nu au fost trimise și au fost șterse la deconectare: {subjects}" + }, + "errors": { + "unavailable": "Pentru asta este nevoie de conexiune. Încercați din nou când sunteți online.", + "plugins_down": "Pluginul de semnare sau criptare nu funcționează offline. Trimiteți mesajul când sunteți din nou online.", + "file_missing": "Un fișier atașat offline nu mai este pe acest dispozitiv. Atașați-l din nou.", + "no_sent_mailbox": "Dosarul Trimise nu a fost găsit.", + "no_drafts_mailbox": "Dosarul Ciorne nu a fost găsit." } }, "deep_link": { diff --git a/locales/ru/common.json b/locales/ru/common.json index b3554903e..0b7080a2b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5667,11 +5667,15 @@ "delete": "Удалить", "delete_confirm_title": "Удалить это письмо?", "delete_confirm_message": "Оно не отправлено, и его нельзя вернуть.", - "changes": "{count, plural, one {# изменение} few {# изменения} many {# изменений} other {# изменения}} прочтения, флажков или папок отправятся, когда вы снова будете в сети." + "changes": "{count, plural, one {# изменение} few {# изменения} many {# изменений} other {# изменения}} прочтения, флажков или папок отправятся, когда вы снова будете в сети.", + "status_held": "Правила вашей организации изменились, пока письмо ждало. Напишите его заново, чтобы отправить.", + "retry_label": "Отправить снова: {subject}", + "delete_label": "Удалить: {subject}" }, "banner": { "offline": "Вы не в сети. Показана почта, сохранённая на этом устройстве. То, что вы измените или напишете, отправится, когда вы снова будете в сети.", - "outbox": "{count, plural, one {# письмо} few {# письма} many {# писем} other {# письма}} в исходящих" + "outbox": "{count, plural, one {# письмо} few {# письма} many {# писем} other {# письма}} в исходящих", + "server_down": "Почтовый сервер недоступен. Показана почта, сохранённая на этом устройстве. То, что вы измените или напишете, отправится, когда он снова ответит." }, "toast": { "queued": "Сохранено в исходящих. Отправится, когда вы снова будете в сети.", @@ -5681,13 +5685,22 @@ "refused_other": "Для этого нужно подключение. Повторите, когда будете в сети.", "forbidden": "Ваша организация выключила офлайн-режим. Почта, сохранённая на этом устройстве, удалена.", "back_online": "Вы снова в сети. Перезагрузите страницу, чтобы войти и отправить ожидающее.", - "reload": "Перезагрузить" + "reload": "Перезагрузить", + "held": "Письмо в исходящих требует вашего внимания: {subject}", + "lost": "Эти письма не были отправлены и удалены: {subjects}" }, "signout": { "title": "Выйти с неотправленными письмами?", "message": "{count, plural, one {# письмо не отправлено.} few {# письма не отправлены.} many {# писем не отправлено.} other {# письма не отправлены.}} При выходе они удалятся.", "confirm": "Выйти", "lost": "Эти письма не были отправлены и удалились при выходе: {subjects}" + }, + "errors": { + "unavailable": "Для этого нужно подключение. Повторите, когда будете в сети.", + "plugins_down": "Ваш плагин подписи или шифрования не работает без сети. Отправьте это, когда снова будете в сети.", + "file_missing": "Файл, прикреплённый без сети, больше не хранится на этом устройстве. Прикрепите его снова.", + "no_sent_mailbox": "Папка «Отправленные» не найдена.", + "no_drafts_mailbox": "Папка «Черновики» не найдена." } }, "deep_link": { diff --git a/locales/sk/common.json b/locales/sk/common.json index fbca3d75e..e7486d730 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5667,11 +5667,15 @@ "delete": "Vymazať", "delete_confirm_title": "Vymazať túto správu?", "delete_confirm_message": "Nebola odoslaná a nedá sa obnoviť.", - "changes": "{count, plural, one {# zmena} few {# zmeny} many {# zmeny} other {# zmien}} prečítania, príznakov alebo priečinkov sa odošle, keď budete znova online." + "changes": "{count, plural, one {# zmena} few {# zmeny} many {# zmeny} other {# zmien}} prečítania, príznakov alebo priečinkov sa odošle, keď budete znova online.", + "status_held": "Pravidlá vašej organizácie sa zmenili, kým správa čakala. Napíšte ju znova, aby ste ju odoslali.", + "retry_label": "Odoslať znova: {subject}", + "delete_label": "Vymazať: {subject}" }, "banner": { "offline": "Ste offline. Vidíte poštu uchovanú na tomto zariadení. Čo zmeníte alebo napíšete, sa odošle, keď budete znova online.", - "outbox": "{count, plural, one {# správa} few {# správy} many {# správy} other {# správ}} na odoslanie" + "outbox": "{count, plural, one {# správa} few {# správy} many {# správy} other {# správ}} na odoslanie", + "server_down": "Poštový server nie je dostupný. Vidíte poštu uchovanú na tomto zariadení. Čo zmeníte alebo napíšete, sa odošle, keď znova odpovie." }, "toast": { "queued": "Uložené na odoslanie. Odošle sa, keď budete znova online.", @@ -5681,13 +5685,22 @@ "refused_other": "Na to je potrebné pripojenie. Skúste to znova, keď budete online.", "forbidden": "Vaša organizácia vypla režim offline. Pošta uchovaná na tomto zariadení bola odstránená.", "back_online": "Ste znova online. Načítajte stránku znova, aby ste sa prihlásili a odoslali, čo čaká.", - "reload": "Načítať znova" + "reload": "Načítať znova", + "held": "Správa na odoslanie potrebuje vašu pozornosť: {subject}", + "lost": "Tieto správy neboli odoslané a boli vymazané: {subjects}" }, "signout": { "title": "Odhlásiť sa s neodoslanými správami?", "message": "{count, plural, one {# správa nebola odoslaná.} few {# správy neboli odoslané.} many {# správy nebolo odoslaných.} other {# správ nebolo odoslaných.}} Odhlásením sa vymažú.", "confirm": "Odhlásiť sa", "lost": "Tieto správy neboli odoslané a pri odhlásení boli vymazané: {subjects}" + }, + "errors": { + "unavailable": "Na to je potrebné pripojenie. Skúste to znova, keď budete online.", + "plugins_down": "Váš doplnok na podpisovanie alebo šifrovanie offline nebeží. Odošlite to, keď budete znova online.", + "file_missing": "Súbor priložený offline už na tomto zariadení nie je. Priložte ho znova.", + "no_sent_mailbox": "Priečinok Odoslané sa nenašiel.", + "no_drafts_mailbox": "Priečinok Koncepty sa nenašiel." } }, "deep_link": { diff --git a/locales/th/common.json b/locales/th/common.json index 8bb321866..c82ba4bf2 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2051,11 +2051,15 @@ "delete": "ลบ", "delete_confirm_title": "ลบข้อความนี้ไหม", "delete_confirm_message": "ข้อความนี้ยังไม่ได้ส่งและกู้คืนไม่ได้", - "changes": "การเปลี่ยนแปลง {count, plural, other {# รายการ}} ของสถานะอ่าน ธง หรือโฟลเดอร์จะถูกส่งเมื่อคุณกลับมาออนไลน์" + "changes": "การเปลี่ยนแปลง {count, plural, other {# รายการ}} ของสถานะอ่าน ธง หรือโฟลเดอร์จะถูกส่งเมื่อคุณกลับมาออนไลน์", + "status_held": "กฎขององค์กรเปลี่ยนไประหว่างที่ข้อความรออยู่ เขียนใหม่เพื่อส่ง", + "retry_label": "ส่งอีกครั้ง: {subject}", + "delete_label": "ลบ: {subject}" }, "banner": { "offline": "คุณออฟไลน์อยู่ คุณกำลังดูอีเมลที่เก็บไว้บนอุปกรณ์นี้ สิ่งที่คุณเปลี่ยนหรือเขียนจะถูกส่งเมื่อกลับมาออนไลน์", - "outbox": "{count, plural, other {# ข้อความ}}ในกล่องขาออก" + "outbox": "{count, plural, other {# ข้อความ}}ในกล่องขาออก", + "server_down": "เชื่อมต่อเซิร์ฟเวอร์อีเมลไม่ได้ คุณกำลังดูอีเมลที่เก็บไว้บนอุปกรณ์นี้ สิ่งที่คุณเปลี่ยนหรือเขียนจะถูกส่งเมื่อเซิร์ฟเวอร์ตอบกลับอีกครั้ง" }, "toast": { "queued": "บันทึกในกล่องขาออกแล้ว จะถูกส่งเมื่อคุณกลับมาออนไลน์", @@ -2065,13 +2069,22 @@ "refused_other": "การทำงานนี้ต้องใช้การเชื่อมต่อ ลองอีกครั้งเมื่อออนไลน์", "forbidden": "องค์กรของคุณปิดโหมดออฟไลน์แล้ว อีเมลที่เก็บไว้บนอุปกรณ์นี้ถูกลบแล้ว", "back_online": "คุณกลับมาออนไลน์แล้ว โหลดใหม่เพื่อเข้าสู่ระบบและส่งสิ่งที่รออยู่", - "reload": "โหลดใหม่" + "reload": "โหลดใหม่", + "held": "ข้อความในกล่องขาออกต้องการให้คุณตรวจดู: {subject}", + "lost": "ข้อความเหล่านี้ไม่ได้ถูกส่งและถูกลบแล้ว: {subjects}" }, "signout": { "title": "ออกจากระบบทั้งที่มีข้อความยังไม่ได้ส่งไหม", "message": "{count, plural, other {มีข้อความ # รายการที่ยังไม่ได้ส่ง}} การออกจากระบบจะลบข้อความเหล่านั้น", "confirm": "ออกจากระบบ", "lost": "ข้อความเหล่านี้ไม่ได้ถูกส่งและถูกลบเมื่อคุณออกจากระบบ: {subjects}" + }, + "errors": { + "unavailable": "การทำงานนี้ต้องใช้การเชื่อมต่อ ลองอีกครั้งเมื่อออนไลน์", + "plugins_down": "ปลั๊กอินลงนามหรือเข้ารหัสของคุณไม่ทำงานเมื่อออฟไลน์ ส่งข้อความนี้เมื่อกลับมาออนไลน์", + "file_missing": "ไฟล์ที่แนบขณะออฟไลน์ไม่อยู่บนอุปกรณ์นี้แล้ว แนบอีกครั้ง", + "no_sent_mailbox": "ไม่พบโฟลเดอร์ที่ส่งแล้ว", + "no_drafts_mailbox": "ไม่พบโฟลเดอร์แบบร่าง" } }, "plugin_pages": { diff --git a/locales/tr/common.json b/locales/tr/common.json index 39a0a0fc7..57d8f7357 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5667,11 +5667,15 @@ "delete": "Sil", "delete_confirm_title": "Bu ileti silinsin mi?", "delete_confirm_message": "Gönderilmedi ve geri getirilemez.", - "changes": "Okundu, bayrak veya klasörlerdeki {count, plural, one {# değişiklik} other {# değişiklik}} yeniden çevrimiçi olduğunuzda gönderilecek." + "changes": "Okundu, bayrak veya klasörlerdeki {count, plural, one {# değişiklik} other {# değişiklik}} yeniden çevrimiçi olduğunuzda gönderilecek.", + "status_held": "İleti beklerken kuruluşunuzun kuralları değişti. Göndermek için yeniden yazın.", + "retry_label": "Yeniden gönder: {subject}", + "delete_label": "Sil: {subject}" }, "banner": { "offline": "Çevrimdışısınız. Bu cihazda tutulan e-postaları görüyorsunuz. Değiştirdikleriniz veya yazdıklarınız yeniden çevrimiçi olunca gönderilir.", - "outbox": "Giden kutusunda {count, plural, one {# ileti} other {# ileti}}" + "outbox": "Giden kutusunda {count, plural, one {# ileti} other {# ileti}}", + "server_down": "Posta sunucusuna ulaşılamıyor. Bu cihazda tutulan e-postaları görüyorsunuz. Değiştirdikleriniz veya yazdıklarınız sunucu yeniden yanıt verince gönderilir." }, "toast": { "queued": "Giden kutusuna kaydedildi. Yeniden çevrimiçi olduğunuzda gönderilir.", @@ -5681,13 +5685,22 @@ "refused_other": "Bunun için bağlantı gerekir. Çevrimiçi olunca yeniden deneyin.", "forbidden": "Kuruluşunuz çevrimdışı modu kapattı. Bu cihazda tutulan e-postalar kaldırıldı.", "back_online": "Yeniden çevrimiçisiniz. Oturum açmak ve bekleyenleri göndermek için yeniden yükleyin.", - "reload": "Yeniden yükle" + "reload": "Yeniden yükle", + "held": "Giden kutusundaki bir ileti ilginizi bekliyor: {subject}", + "lost": "Bu iletiler gönderilmedi ve silindi: {subjects}" }, "signout": { "title": "Gönderilmemiş iletilerle oturum kapatılsın mı?", "message": "{count, plural, one {# ileti gönderilmedi.} other {# ileti gönderilmedi.}} Oturumu kapatmak bunları siler.", "confirm": "Oturumu kapat", "lost": "Bu iletiler gönderilmedi ve oturumunuz kapatılırken silindi: {subjects}" + }, + "errors": { + "unavailable": "Bunun için bağlantı gerekir. Çevrimiçi olunca yeniden deneyin.", + "plugins_down": "İmzalama veya şifreleme eklentiniz çevrimdışı çalışmıyor. Yeniden çevrimiçi olunca bunu gönderin.", + "file_missing": "Çevrimdışıyken eklenen bir dosya artık bu cihazda değil. Yeniden ekleyin.", + "no_sent_mailbox": "Gönderilenler klasörü bulunamadı.", + "no_drafts_mailbox": "Taslaklar klasörü bulunamadı." } }, "deep_link": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 1caf2be2d..bbb942dd4 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5667,11 +5667,15 @@ "delete": "Видалити", "delete_confirm_title": "Видалити цей лист?", "delete_confirm_message": "Його не надіслано, і його не можна повернути.", - "changes": "{count, plural, one {# зміну} few {# зміни} many {# змін} other {# зміни}} прочитання, позначок або тек буде надіслано, коли ви знову будете в мережі." + "changes": "{count, plural, one {# зміну} few {# зміни} many {# змін} other {# зміни}} прочитання, позначок або тек буде надіслано, коли ви знову будете в мережі.", + "status_held": "Правила вашої організації змінилися, поки лист чекав. Напишіть його знову, щоб надіслати.", + "retry_label": "Надіслати знову: {subject}", + "delete_label": "Видалити: {subject}" }, "banner": { "offline": "Ви не в мережі. Показано пошту, збережену на цьому пристрої. Те, що ви зміните або напишете, буде надіслано, коли ви знову будете в мережі.", - "outbox": "{count, plural, one {# лист} few {# листи} many {# листів} other {# листа}} у вихідних" + "outbox": "{count, plural, one {# лист} few {# листи} many {# листів} other {# листа}} у вихідних", + "server_down": "Поштовий сервер недоступний. Показано пошту, збережену на цьому пристрої. Те, що ви зміните або напишете, буде надіслано, коли він знову відповість." }, "toast": { "queued": "Збережено у вихідних. Його буде надіслано, коли ви знову будете в мережі.", @@ -5681,13 +5685,22 @@ "refused_other": "Для цього потрібне з'єднання. Спробуйте знову, коли будете в мережі.", "forbidden": "Ваша організація вимкнула офлайн-режим. Пошту, збережену на цьому пристрої, видалено.", "back_online": "Ви знову в мережі. Перезавантажте сторінку, щоб увійти й надіслати те, що чекає.", - "reload": "Перезавантажити" + "reload": "Перезавантажити", + "held": "Лист у вихідних потребує вашої уваги: {subject}", + "lost": "Ці листи не було надіслано, їх видалено: {subjects}" }, "signout": { "title": "Вийти з ненадісланими листами?", "message": "{count, plural, one {# лист не надіслано.} few {# листи не надіслано.} many {# листів не надіслано.} other {# листа не надіслано.}} Під час виходу їх буде видалено.", "confirm": "Вийти", "lost": "Ці листи не було надіслано, і їх видалено під час виходу: {subjects}" + }, + "errors": { + "unavailable": "Для цього потрібне з'єднання. Спробуйте знову, коли будете в мережі.", + "plugins_down": "Ваш плагін підпису або шифрування не працює без мережі. Надішліть це, коли знову будете в мережі.", + "file_missing": "Файлу, прикріпленого без мережі, більше немає на цьому пристрої. Прикріпіть його знову.", + "no_sent_mailbox": "Теку «Надіслані» не знайдено.", + "no_drafts_mailbox": "Теку «Чернетки» не знайдено." } }, "deep_link": { diff --git a/locales/vi/common.json b/locales/vi/common.json index 588bb47cd..d5b9b244b 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5679,11 +5679,15 @@ "delete": "Xóa", "delete_confirm_title": "Xóa thư này?", "delete_confirm_message": "Thư chưa được gửi và không thể khôi phục.", - "changes": "{count, plural, other {# thay đổi}} về trạng thái đọc, cờ hoặc thư mục sẽ được gửi khi bạn trực tuyến trở lại." + "changes": "{count, plural, other {# thay đổi}} về trạng thái đọc, cờ hoặc thư mục sẽ được gửi khi bạn trực tuyến trở lại.", + "status_held": "Quy tắc của tổ chức đã thay đổi trong lúc thư chờ. Hãy viết lại để gửi.", + "retry_label": "Gửi lại: {subject}", + "delete_label": "Xóa: {subject}" }, "banner": { "offline": "Bạn đang ngoại tuyến. Bạn đang xem thư được giữ trên thiết bị này. Những gì bạn thay đổi hoặc viết sẽ được gửi khi bạn trực tuyến trở lại.", - "outbox": "{count, plural, other {# thư}} trong Hộp thư đi" + "outbox": "{count, plural, other {# thư}} trong Hộp thư đi", + "server_down": "Không thể kết nối máy chủ thư. Bạn đang xem thư được giữ trên thiết bị này. Những gì bạn thay đổi hoặc viết sẽ được gửi khi máy chủ phản hồi trở lại." }, "toast": { "queued": "Đã lưu vào Hộp thư đi. Thư sẽ được gửi khi bạn trực tuyến trở lại.", @@ -5693,13 +5697,22 @@ "refused_other": "Việc này cần có kết nối. Hãy thử lại khi bạn trực tuyến.", "forbidden": "Tổ chức của bạn đã tắt chế độ ngoại tuyến. Thư được giữ trên thiết bị này đã bị xóa.", "back_online": "Bạn đã trực tuyến trở lại. Hãy tải lại để đăng nhập và gửi những gì đang chờ.", - "reload": "Tải lại" + "reload": "Tải lại", + "held": "Một thư trong Hộp thư đi cần bạn xem: {subject}", + "lost": "Các thư này chưa được gửi và đã bị xóa: {subjects}" }, "signout": { "title": "Đăng xuất khi còn thư chưa gửi?", "message": "{count, plural, other {Có # thư chưa được gửi.}} Đăng xuất sẽ xóa chúng.", "confirm": "Đăng xuất", "lost": "Các thư này chưa được gửi và đã bị xóa khi bạn bị đăng xuất: {subjects}" + }, + "errors": { + "unavailable": "Việc này cần có kết nối. Hãy thử lại khi bạn trực tuyến.", + "plugins_down": "Plugin ký hoặc mã hóa của bạn không chạy khi ngoại tuyến. Hãy gửi thư này khi bạn trực tuyến trở lại.", + "file_missing": "Tệp đính kèm khi ngoại tuyến không còn trên thiết bị này. Hãy đính kèm lại.", + "no_sent_mailbox": "Không tìm thấy thư mục Thư đã gửi.", + "no_drafts_mailbox": "Không tìm thấy thư mục Thư nháp." } }, "deep_link": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 6af23a33f..c608c145e 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5667,11 +5667,15 @@ "delete": "刪除", "delete_confirm_title": "刪除這封郵件?", "delete_confirm_message": "它尚未寄出,刪除後無法復原。", - "changes": "{count, plural, other {# 項}}已讀、標記或資料夾的變更將在恢復連線後送出。" + "changes": "{count, plural, other {# 項}}已讀、標記或資料夾的變更將在恢復連線後送出。", + "status_held": "等待期間你的組織規則有所變更。請重新撰寫後再寄出。", + "retry_label": "再次寄出:{subject}", + "delete_label": "刪除:{subject}" }, "banner": { "offline": "你已離線。顯示的是此裝置上保留的郵件。你所做的變更或寫的郵件會在恢復連線後送出。", - "outbox": "寄件匣中有 {count, plural, other {# 封}}" + "outbox": "寄件匣中有 {count, plural, other {# 封}}", + "server_down": "無法連線到郵件伺服器。顯示的是此裝置上保留的郵件。你所做的變更或寫的郵件會在伺服器恢復回應後寄出。" }, "toast": { "queued": "已儲存到寄件匣。恢復連線後會寄出。", @@ -5681,13 +5685,22 @@ "refused_other": "此操作需要網路連線。請在連線後重試。", "forbidden": "你的組織關閉了離線模式。此裝置上保留的郵件已被刪除。", "back_online": "已恢復連線。請重新載入以登入並寄出等待中的內容。", - "reload": "重新載入" + "reload": "重新載入", + "held": "寄件匣中有一封郵件需要你處理:{subject}", + "lost": "這些郵件未寄出,已被刪除:{subjects}" }, "signout": { "title": "有未寄出的郵件,仍要登出?", "message": "{count, plural, other {# 封郵件尚未寄出。}}登出會刪除它們。", "confirm": "登出", "lost": "這些郵件未寄出,並在你登出時被刪除:{subjects}" + }, + "errors": { + "unavailable": "此操作需要網路連線。請在連線後重試。", + "plugins_down": "你的簽署或加密外掛程式在離線時無法執行。請在恢復連線後再寄出。", + "file_missing": "離線時附加的檔案已不在此裝置上。請重新附加。", + "no_sent_mailbox": "找不到「寄件備份」資料夾。", + "no_drafts_mailbox": "找不到「草稿」資料夾。" } }, "deep_link": { diff --git a/locales/zh/common.json b/locales/zh/common.json index cfdf3a5af..868c1a845 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5667,11 +5667,15 @@ "delete": "删除", "delete_confirm_title": "删除这封邮件?", "delete_confirm_message": "它尚未发送,删除后无法恢复。", - "changes": "{count, plural, other {# 项}}已读、标记或文件夹的更改将在恢复联网后发送。" + "changes": "{count, plural, other {# 项}}已读、标记或文件夹的更改将在恢复联网后发送。", + "status_held": "等待期间你的组织规则发生了变化。请重新撰写后再发送。", + "retry_label": "再次发送:{subject}", + "delete_label": "删除:{subject}" }, "banner": { "offline": "你已离线。显示的是此设备上保留的邮件。你所做的更改或写的邮件会在恢复联网后发送。", - "outbox": "发件箱中有 {count, plural, other {# 封}}" + "outbox": "发件箱中有 {count, plural, other {# 封}}", + "server_down": "无法连接邮件服务器。显示的是此设备上保留的邮件。你所做的更改或写的邮件会在服务器恢复响应后发送。" }, "toast": { "queued": "已保存到发件箱。恢复联网后会发送。", @@ -5681,13 +5685,22 @@ "refused_other": "此操作需要网络连接。请在联网后重试。", "forbidden": "你的组织关闭了离线模式。此设备上保留的邮件已被删除。", "back_online": "已恢复联网。请重新加载以登录并发送等待中的内容。", - "reload": "重新加载" + "reload": "重新加载", + "held": "发件箱中有一封邮件需要你处理:{subject}", + "lost": "这些邮件未发送,已被删除:{subjects}" }, "signout": { "title": "有未发送的邮件,仍要退出登录?", "message": "{count, plural, other {# 封邮件尚未发送。}}退出登录会删除它们。", "confirm": "退出登录", "lost": "这些邮件未发送,并在你退出登录时被删除:{subjects}" + }, + "errors": { + "unavailable": "此操作需要网络连接。请在联网后重试。", + "plugins_down": "你的签名或加密插件在离线时无法运行。请在恢复联网后再发送。", + "file_missing": "离线时附加的文件已不在此设备上。请重新附加。", + "no_sent_mailbox": "找不到“已发送”文件夹。", + "no_drafts_mailbox": "找不到“草稿”文件夹。" } }, "deep_link": { diff --git a/next.config.ts b/next.config.ts index c18499672..ba49d2cc2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -116,7 +116,12 @@ const nextConfig: NextConfig = { // Lets people see a site runs Bulwark. No version, so it tells // a scanner nothing about which fixes are missing. source: "/:path*", - headers: [{ key: "X-Powered-By", value: "bulwarkmail.org" }], + headers: [ + { key: "X-Powered-By", value: "bulwarkmail.org" }, + // Which build answered (#523): the service worker files a page + // or a chunk under that build, not under its own. + { key: "X-Bulwark-Build", value: buildId }, + ], }, { // Untrusted plugin iframes have opaque origins and load these public, diff --git a/public/sw.js b/public/sw.js index eb628b34b..390de31c3 100644 --- a/public/sw.js +++ b/public/sw.js @@ -151,6 +151,16 @@ async function cachedDocument(url) { return null; } +/** + * The cache of the build that answered (the X-Bulwark-Build header): after a + * deploy the old worker still fetches the new build's pages and chunks, which + * belong in the new build's cache, not in its own. + */ +function shellCacheFor(response) { + const build = response.headers.get("x-bulwark-build") || ""; + return /^[\w.-]{1,80}$/.test(build) ? SHELL_CACHE_PREFIX + build : SHELL_CACHE; +} + async function respondWithDocument(request) { const url = new URL(request.url); try { @@ -158,7 +168,7 @@ async function respondWithDocument(request) { const type = response.headers.get("content-type") || ""; if (response.ok && response.type === "basic" && !response.redirected && type.includes("text/html")) { const copy = response.clone(); - caches.open(SHELL_CACHE).then((cache) => cache.put(documentKey(url), copy)).catch(() => {}); + caches.open(shellCacheFor(response)).then((cache) => cache.put(documentKey(url), copy)).catch(() => {}); } return response; } catch (error) { @@ -174,7 +184,7 @@ async function respondWithStatic(request) { const response = await fetch(request); if (response.ok && response.type === "basic") { const copy = response.clone(); - caches.open(SHELL_CACHE).then((cache) => cache.put(request.url, copy)).catch(() => {}); + caches.open(shellCacheFor(response)).then((cache) => cache.put(request.url, copy)).catch(() => {}); } return response; } diff --git a/stores/__tests__/auth-store-signout-gate.test.ts b/stores/__tests__/auth-store-signout-gate.test.ts new file mode 100644 index 000000000..4aa2fda74 --- /dev/null +++ b/stores/__tests__/auth-store-signout-gate.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as browserNavigation from '@/lib/browser-navigation'; +import { clearAllHooks } from '@/lib/plugin-hooks'; +import { addSignOutGate } from '@/lib/sign-out-cleanup'; +import { useAuthStore } from '../auth-store'; +import { useAccountStore } from '../account-store'; + +// Offline mode (#523): a sign-out the user starts may be stopped by the +// question about unsent mail; an automatic one (the session limits, a +// refused renewal) is never held up by it. +describe('sign-out gates', () => { + let removeGate: () => void = () => {}; + + beforeEach(() => { + removeGate(); + vi.restoreAllMocks(); + clearAllHooks(); + sessionStorage.clear(); + localStorage.clear(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); + vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {}); + useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null }); + useAuthStore.setState({ isAuthenticated: true, authMode: 'basic', client: null, activeAccountId: null }); + }); + + it('keeps everyone signed in when the user says no', async () => { + const gate = vi.fn(async () => false); + removeGate = addSignOutGate(gate); + await useAuthStore.getState().logout(); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + await useAuthStore.getState().logoutAll(); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + expect(gate).toHaveBeenCalledTimes(2); + }); + + it('signs out when the user says yes', async () => { + removeGate = addSignOutGate(async () => true); + await useAuthStore.getState().logoutAll(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + + it('never asks at an automatic sign-out', async () => { + const gate = vi.fn(async () => false); + removeGate = addSignOutGate(gate); + await useAuthStore.getState().logoutAll({ endProviderSession: false }); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + useAuthStore.setState({ isAuthenticated: true }); + await useAuthStore.getState().logout({ endProviderSession: false }); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(gate).not.toHaveBeenCalled(); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index e4dd3b343..380379382 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -136,6 +136,12 @@ interface AuthState { refreshIdentities: () => Promise; getClientForAccount: (accountId: string) => JMAPClient | undefined; getAllConnectedClients: () => Map; + /** + * After a start without the server (offline mode, #523): sign the + * accounts that run from this device in for real, now that the server + * answers. True when every one of them got a real client. + */ + reconnectOfflineClients: () => Promise; } export interface LogoutOptions { @@ -1319,6 +1325,9 @@ async function startOfflineClient(accountId: string, serverUrl: string, username if (IS_LITE) return null; try { const offline = await import('@/lib/offline/offline-mode'); + // Only without a network or a server: a server that refused one request + // (an overload without CORS headers, a 5xx) gets the usual error. + if (!(await offline.serverUnreachable(serverUrl))) return null; const session = await offline.offlineSessionFor(accountId); if (!session) return null; offline.markStartedOffline(); @@ -3278,6 +3287,17 @@ export const useAuthStore = create()( getAllConnectedClients: () => { return new Map(clients); }, + + reconnectOfflineClients: async () => { + const offline = [...clients.entries()].filter(([, c]) => c.isOfflineOnly()); + if (offline.length === 0) return true; + for (const [id] of offline) clients.delete(id); + await get().checkAuth(); + return offline.every(([id]) => { + const client = clients.get(id); + return !!client && !client.isOfflineOnly(); + }); + }, }), { name: 'auth-storage', diff --git a/stores/email-store.ts b/stores/email-store.ts index 9a111c741..9fd4c63e2 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -4335,9 +4335,10 @@ export const useEmailStore = create((set, get) => ({ }); return result; } catch (error) { - // The browser went offline on the way, before anything reached the - // server (the client refused without a network): keep it. - if (outbox && error instanceof Error && error.name === 'OfflineUnavailableError' && outbox.shouldQueue(client)) { + // The network went away before anything was handed to the server + // (the client says so: SendNotStartedError): keep it. Any other + // failure may have come after the server took the message. + if (outbox && error instanceof Error && error.name === 'SendNotStartedError' && outbox.shouldQueue(client)) { set({ isLoading: false }); return outbox.queueEmail(client, queueArgs()); }