mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
feat: keep recent mail on this device and send mail written offline #523
Settings > Backup & reset gets "Keep mail on this device", off until the user turns it on in this browser. While it is on: - the JMAP client hands the server's answers to an encrypted cache in IndexedDB (AES-GCM, non-extractable key) and answers from it when the network is gone: the folder list, the last 30 days of the Inbox and of the folders the user opened, bodies and attachments, at most 250 MB, oldest out first; a background sync keeps it current - read, flag, tags and moves made offline are queued and sent in order when the server answers again; deleting for good and emptying a folder are refused offline - the composer, quick reply and the S/MIME and PGP plugins' sends that complete the composer's go to an outbox once every step up to the transport ran (checks, label, signature, plugin send checks); only the finished send waits, with files attached offline - the service worker keeps this build's app shell, so the app also starts offline from the kept session and the last policy - a full sign-out, the idle sign-out and signing one account out delete it, a sign-out the user starts asks first when mail is unsent, and the login page names what an automatic sign-out deleted; a policy fetch that forbids it (devices.noOffline) wipes that account Not in Lite.
This commit is contained in:
@@ -26,6 +26,7 @@ import { PluginAppEvents } from "@/components/providers/plugin-app-events";
|
||||
import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth-callback-listener";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { AppUpdatePrompt } from "@/components/app-update-prompt";
|
||||
import { OfflineHost } from "@/components/offline/offline-host";
|
||||
import { DevicePolicyHost } from "@/components/device/device-policy-host";
|
||||
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
|
||||
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
|
||||
@@ -100,6 +101,8 @@ export default async function LocaleLayout({
|
||||
<PluginPageNavigator />
|
||||
<PWAInstallPrompt />
|
||||
<AppUpdatePrompt />
|
||||
{/* Offline mode isn't in Lite (no service worker there). */}
|
||||
{!IS_LITE && <OfflineHost />}
|
||||
<DevicePolicyHost />
|
||||
<PushNotificationPrompt />
|
||||
{/* Lite is a static bundle: no server keeps an archive there. */}
|
||||
|
||||
@@ -183,6 +183,7 @@ function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("login");
|
||||
const tSessionLimits = useTranslations("session_limits");
|
||||
const tOffline = useTranslations("offline");
|
||||
const tTheme = useTranslations("settings.appearance.theme");
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -1418,6 +1419,11 @@ function LoginPageContent() {
|
||||
{(sessionEnd?.draftsUnsaved || expiredDraftsUnsaved) && (
|
||||
<span className="block mt-1" data-testid="session-drafts-not-saved">{tSessionLimits("drafts_not_saved")}</span>
|
||||
)}
|
||||
{sessionEnd?.outboxLost?.length ? (
|
||||
<span className="block mt-1 [overflow-wrap:anywhere]" data-testid="session-outbox-lost">
|
||||
{tOffline("signout.lost", { subjects: sessionEnd.outboxLost.map((s) => s || tOffline("outbox.no_subject")).join(", ") })}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
|
||||
/** Files a page can load: scripts, styles, fonts and the images they name. */
|
||||
const SHELL_FILE = /\.(js|css|woff2?|svg|png|webp)$/;
|
||||
|
||||
async function listFiles(dir: string, prefix: string, out: string[]): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) await listFiles(join(dir, entry.name), `${prefix}${entry.name}/`, out);
|
||||
else if (SHELL_FILE.test(entry.name)) out.push(`${prefix}${entry.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The files of this build's app shell (#523): everything under
|
||||
* /_next/static/, which is the same for every user and content-hashed. A
|
||||
* tab with offline mode on hands the list to the service worker, which keeps
|
||||
* the files in this build's cache so the app also starts without a network.
|
||||
*/
|
||||
export async function GET() {
|
||||
const files: string[] = [];
|
||||
await listFiles(join(process.cwd(), '.next', 'static'), `${BASE_PATH}/_next/static/`, files);
|
||||
return NextResponse.json(
|
||||
{ buildId: process.env.NEXT_PUBLIC_BUILD_ID ?? '', files },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
@@ -113,6 +113,7 @@ import { wantsSignatureSeparator } from "@/lib/signature-delimiter";
|
||||
import { finalizeOutgoing, runPreSendChecks, type OutgoingContext } from "@/lib/outgoing/send-pipeline";
|
||||
import { classifyOutgoing, isLabelRequiredError, labelMissing, labelsPolicyFor, resolveLabel, type OriginalMessage } from "@/lib/outgoing/classification";
|
||||
import { beginLabelTakeover, endLabelTakeover } from "@/lib/outgoing/classification-raw";
|
||||
import { beginComposeTakeover, endComposeTakeover } from "@/lib/outgoing/compose-takeover";
|
||||
import { recipientsChanged, runPluginSendCheck, sendGuardMessageKey } from "@/lib/outgoing/plugin-send-check";
|
||||
import { externalRecipientsFor, inspectAttachments, sniffFile } from "@/lib/outgoing/send-check-payload";
|
||||
import { whenPolicySettled } from "@/stores/policy-store";
|
||||
@@ -3707,10 +3708,12 @@ export function EmailComposer({
|
||||
],
|
||||
};
|
||||
let pluginOutcome: Awaited<ReturnType<typeof emailHooks.onComposeSend.takeOver>>;
|
||||
beginComposeTakeover();
|
||||
try {
|
||||
pluginOutcome = await emailHooks.onComposeSend.takeOver(composeSendRequest);
|
||||
} finally {
|
||||
endLabelTakeover(labelToken);
|
||||
endComposeTakeover();
|
||||
}
|
||||
if (pluginOutcome === 'aborted') {
|
||||
// The plugin shows its own error, or the user cancelled its prompt.
|
||||
|
||||
@@ -78,6 +78,7 @@ export {
|
||||
IconClock as Clock,
|
||||
IconClockHour3 as Clock3,
|
||||
IconCloud as Cloud,
|
||||
IconCloudOff as CloudOff,
|
||||
IconCode as Code,
|
||||
IconColumns3 as Columns3,
|
||||
IconCopy as Copy,
|
||||
|
||||
@@ -2280,7 +2280,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
// Delayed sends get the undo toast instead; a filing error or a refused
|
||||
// recipient already warned.
|
||||
if (!result.filingError && !result.rejectedRecipients?.length) toast.success(t('notifications.email_sent'));
|
||||
// A message kept in the offline outbox said so itself (#523).
|
||||
if (!result.filingError && !result.rejectedRecipients?.length && !result.queued) toast.success(t('notifications.email_sent'));
|
||||
|
||||
// Refresh the current mailbox to update the UI
|
||||
if (!isScheduledView) {
|
||||
@@ -4179,7 +4180,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
threading?.references,
|
||||
delayedUntil,
|
||||
undefined,
|
||||
labelled.labelHeaders ? { labelHeaders: labelled.labelHeaders } : undefined,
|
||||
// Every step ran, so offline mode may keep the reply in its outbox (#523).
|
||||
{ ...(labelled.labelHeaders ? { labelHeaders: labelled.labelHeaders } : {}), outbox: true },
|
||||
);
|
||||
// As after the composer's send: the server refused some recipients, held
|
||||
// reply or not, and nothing else would say they never get it.
|
||||
@@ -4241,7 +4243,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!refused) toast.success(t('notifications.email_sent'));
|
||||
if (!refused && !result.queued) toast.success(t('notifications.email_sent'));
|
||||
|
||||
// The reply is out: a failed refresh from here on must not reach the
|
||||
// quick-reply box, which would report the send as failed and invite a
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const offline = vi.hoisted(() => ({ enableOffline: vi.fn(async () => {}), disableOffline: vi.fn(async () => {}) }));
|
||||
vi.mock('@/lib/offline/offline-mode', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/offline/offline-mode')>()),
|
||||
enableOffline: offline.enableOffline,
|
||||
disableOffline: offline.disableOffline,
|
||||
}));
|
||||
|
||||
import { OfflineSettings } from '../offline-settings';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
|
||||
function setNoOffline(noOffline: boolean) {
|
||||
usePolicyStore.setState({
|
||||
loaded: true,
|
||||
subject: null,
|
||||
policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, noOffline } } },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
offline.enableOffline.mockClear();
|
||||
offline.disableOffline.mockClear();
|
||||
useAuthStore.setState({ activeAccountId: 'acc' });
|
||||
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, syncing: [], lastSyncedAt: {} });
|
||||
setNoOffline(false);
|
||||
});
|
||||
|
||||
describe('Settings > Offline', () => {
|
||||
it('is off until the user turns it on in this browser', () => {
|
||||
render(<OfflineSettings />);
|
||||
const toggle = screen.getByTestId('offline-switch');
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false');
|
||||
fireEvent.click(toggle);
|
||||
expect(offline.enableOffline).toHaveBeenCalledWith('acc');
|
||||
});
|
||||
|
||||
it('asks before turning off with unsent messages', () => {
|
||||
useOfflineStore.setState({ enabled: ['acc'], outbox: [{ id: 1, account: 'acc', createdAt: Date.now(), subject: 'Hi', to: ['b@example.org'], status: 'waiting' }] });
|
||||
render(<OfflineSettings />);
|
||||
fireEvent.click(screen.getByTestId('offline-switch'));
|
||||
expect(offline.disableOffline).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('off_confirm_title')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'off_confirm' }));
|
||||
expect(offline.disableOffline).toHaveBeenCalledWith('acc');
|
||||
});
|
||||
|
||||
it('hides the switch and says why when the organisation forbids it', () => {
|
||||
setNoOffline(true);
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.queryByTestId('offline-switch')).toBeNull();
|
||||
expect(screen.getByTestId('offline-forbidden').textContent).toContain('forbidden');
|
||||
});
|
||||
|
||||
it('follows a policy that changes while it is open', () => {
|
||||
render(<OfflineSettings />);
|
||||
expect(screen.getByTestId('offline-switch')).toBeInTheDocument();
|
||||
act(() => setNoOffline(true));
|
||||
expect(screen.queryByTestId('offline-switch')).toBeNull();
|
||||
});
|
||||
|
||||
it('lists the outbox', () => {
|
||||
useOfflineStore.setState({ outbox: [{ id: 1, account: 'acc', createdAt: Date.now(), subject: 'Quarterly', to: ['b@example.org'], status: 'failed', error: 'Rejected' }] });
|
||||
render(<OfflineSettings />);
|
||||
const rows = screen.getAllByTestId('offline-outbox-row');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].textContent).toContain('Quarterly');
|
||||
expect(rows[0].getAttribute('data-status')).toBe('failed');
|
||||
expect(screen.getByRole('button', { name: 'retry' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CloudOff } from '@/components/icons';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { buildSettingsPath } from '@/lib/deep-links';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
/**
|
||||
* A strip across the top of the apps while offline mode (#523) answers from
|
||||
* this device: the app is offline, and how many messages wait in the
|
||||
* outbox (a link to it). Shown only for an account that keeps mail here.
|
||||
*/
|
||||
export function OfflineBanner() {
|
||||
const t = useTranslations('offline.banner');
|
||||
const router = useRouter();
|
||||
const account = useAuthStore((s) => s.activeAccountId);
|
||||
const active = useOfflineStore((s) => !!account && s.enabled.includes(account));
|
||||
const offline = useOfflineStore((s) => s.browserOffline || s.networkDown || s.startedOffline);
|
||||
const waiting = useOfflineStore((s) => s.outbox.length);
|
||||
if (IS_LITE || !active || (!offline && waiting === 0)) return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="offline-banner"
|
||||
className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-b border-border bg-muted px-3 py-2 text-sm text-foreground sm:px-4"
|
||||
>
|
||||
{offline && (
|
||||
<span className="flex min-w-0 items-start gap-2">
|
||||
<CloudOff className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 [overflow-wrap:anywhere]">{t('offline')}</span>
|
||||
</span>
|
||||
)}
|
||||
{waiting > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="offline-banner-outbox"
|
||||
onClick={() => router.push(buildSettingsPath('backup', 'offline'))}
|
||||
className="min-h-11 rounded-md px-1 font-medium underline underline-offset-2 hover:bg-foreground/5 sm:min-h-0"
|
||||
>
|
||||
{t('outbox', { count: waiting })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
|
||||
import { addSignOutGate } from '@/lib/sign-out-cleanup';
|
||||
import { reloadToNewBuild } from '@/lib/app-update';
|
||||
import { setOfflineNotifier, startOfflineMode, type OfflineNotice } from '@/lib/offline/offline-mode';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
/**
|
||||
* Runs offline mode (#523) in this tab: the JMAP client's offline hooks,
|
||||
* the outbox, the wipes. Its notices become toasts, and a sign-out the
|
||||
* user asked for while messages wait in the outbox asks first. Not mounted
|
||||
* in Lite (no offline mode there).
|
||||
*/
|
||||
export function OfflineHost() {
|
||||
const t = useTranslations('offline');
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const { dialogProps, confirm } = useConfirmDialog();
|
||||
const confirmRef = useRef(confirm);
|
||||
confirmRef.current = confirm;
|
||||
|
||||
useEffect(() => {
|
||||
const show = (notice: OfflineNotice) => {
|
||||
const tr = tRef.current;
|
||||
switch (notice.kind) {
|
||||
case 'queued':
|
||||
toast.info(tr('toast.queued'));
|
||||
break;
|
||||
case 'sent':
|
||||
toast.success(tr('toast.sent', { subject: notice.subject || tr('outbox.no_subject') }));
|
||||
break;
|
||||
case 'send-failed':
|
||||
toast.error(tr('toast.send_failed', { subject: notice.subject || tr('outbox.no_subject') }), { message: notice.error });
|
||||
break;
|
||||
case 'refused-delete':
|
||||
toast.warning(tr('toast.refused_delete'));
|
||||
break;
|
||||
case 'refused-other':
|
||||
toast.warning(tr('toast.refused_other'));
|
||||
break;
|
||||
case 'forbidden':
|
||||
toast.warning(tr('toast.forbidden'));
|
||||
break;
|
||||
case 'back-online-reload':
|
||||
toast.info(tr('toast.back_online'), {
|
||||
duration: 0,
|
||||
action: { label: tr('toast.reload'), onClick: () => { void reloadToNewBuild(); } },
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
setOfflineNotifier(show);
|
||||
const stop = startOfflineMode();
|
||||
// Messages that would be lost: ask before a sign-out the user started.
|
||||
const removeGate = addSignOutGate(async (accountId) => {
|
||||
const unsent = useOfflineStore.getState().outbox.filter((row) => accountId === 'all' || row.account === accountId).length;
|
||||
if (unsent === 0) return true;
|
||||
const tr = tRef.current;
|
||||
return confirmRef.current({
|
||||
title: tr('signout.title'),
|
||||
message: tr('signout.message', { count: unsent }),
|
||||
confirmText: tr('signout.confirm'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
removeGate();
|
||||
stop();
|
||||
setOfflineNotifier(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <ConfirmDialog {...dialogProps} />;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { AlertTriangle } from '@/components/icons';
|
||||
import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section';
|
||||
import { OrgLockedNote } from '@/components/settings/org-locked-note';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { formatFileSize } from '@/lib/utils';
|
||||
import { disableOffline, enableOffline, offlineAllowed, offlineSupported } from '@/lib/offline/offline-mode';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
import { useDevicePolicy } from '@/hooks/use-device-policy';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { OutboxList } from './outbox-list';
|
||||
|
||||
/**
|
||||
* Settings > Backup & reset > Offline (#523): "Keep mail on this device"
|
||||
* for the active account, the space it uses, and the outbox. Not in Lite.
|
||||
* When the organisation forbids it, the switch is gone and a note says so.
|
||||
*/
|
||||
export function OfflineSettings() {
|
||||
const t = useTranslations('offline.settings');
|
||||
const format = useFormatter();
|
||||
const account = useAuthStore((s) => s.activeAccountId);
|
||||
// Re-renders when the organisation's device settings change.
|
||||
const devices = useDevicePolicy();
|
||||
const enabled = useOfflineStore((s) => !!account && s.enabled.includes(account));
|
||||
const usage = useOfflineStore((s) => s.usageBytes);
|
||||
const syncing = useOfflineStore((s) => !!account && s.syncing.includes(account));
|
||||
const lastSynced = useOfflineStore((s) => (account ? s.lastSyncedAt[account] : undefined));
|
||||
const unsent = useOfflineStore((s) => s.outbox.filter((row) => row.account === account).length);
|
||||
const [confirmOff, setConfirmOff] = useState(false);
|
||||
|
||||
if (IS_LITE) return null;
|
||||
const allowed = !devices.noOffline && offlineAllowed(account);
|
||||
|
||||
const turnOff = () => {
|
||||
if (!account) return;
|
||||
void disableOffline(account);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection id="offline" title={t('title')} description={t('description')}>
|
||||
{!offlineSupported() ? (
|
||||
<p className="text-sm text-muted-foreground" data-testid="offline-unsupported">{t('unsupported')}</p>
|
||||
) : !allowed ? (
|
||||
<div className="py-3" data-testid="offline-forbidden">
|
||||
<p className="text-sm font-medium text-foreground">{t('keep_label')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('forbidden')}</p>
|
||||
<OrgLockedNote className="mt-1" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SettingItem label={t('keep_label')} description={t('keep_description')}>
|
||||
<ToggleSwitch
|
||||
checked={enabled}
|
||||
testId="offline-switch"
|
||||
onChange={(on) => {
|
||||
if (!account) return;
|
||||
if (on) void enableOffline(account);
|
||||
else if (unsent > 0) setConfirmOff(true);
|
||||
else turnOff();
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<p>{t('warning')}</p>
|
||||
</div>
|
||||
{enabled && (
|
||||
<p className="text-xs text-muted-foreground" role="status" aria-live="polite" data-testid="offline-status">
|
||||
{syncing
|
||||
? t('syncing')
|
||||
: lastSynced
|
||||
? t('usage_updated', { size: formatFileSize(usage), time: format.relativeTime(lastSynced, Date.now()) })
|
||||
: t('usage', { size: formatFileSize(usage) })}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<OutboxList />
|
||||
<ConfirmDialog
|
||||
isOpen={confirmOff}
|
||||
onClose={() => setConfirmOff(false)}
|
||||
onConfirm={() => { setConfirmOff(false); turnOff(); }}
|
||||
title={t('off_confirm_title')}
|
||||
message={t('off_confirm_message', { count: unsent })}
|
||||
confirmText={t('off_confirm')}
|
||||
variant="destructive"
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { deleteOutboxEntry, retryOutboxEntry } from '@/lib/offline/flush';
|
||||
import { refreshQueueState, scheduleFlushAll } from '@/lib/offline/offline-mode';
|
||||
import { isOfflineNow, useOfflineStore, type OutboxRow } from '@/lib/offline/state';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
/**
|
||||
* The offline outbox (#523): messages written offline that wait for the
|
||||
* network, with what happened to them. A message that couldn't be sent can
|
||||
* be sent again or deleted; one whose connection broke on the way is never
|
||||
* sent again on its own (it may have gone out).
|
||||
*/
|
||||
export function OutboxList() {
|
||||
const t = useTranslations('offline.outbox');
|
||||
const format = useFormatter();
|
||||
const rows = useOfflineStore((s) => s.outbox);
|
||||
const changes = useOfflineStore((s) => s.pendingChanges);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const [deleting, setDeleting] = useState<OutboxRow | null>(null);
|
||||
const several = new Set(rows.map((r) => r.account)).size > 1 || accounts.length > 1;
|
||||
|
||||
const status = (row: OutboxRow) => {
|
||||
switch (row.status) {
|
||||
case 'sending': return t('status_sending');
|
||||
case 'failed': return t('status_failed', { error: row.error ?? '' });
|
||||
case 'uncertain': return t('status_uncertain');
|
||||
default: return t('status_waiting');
|
||||
}
|
||||
};
|
||||
|
||||
const retry = async (row: OutboxRow) => {
|
||||
await retryOutboxEntry(row.id);
|
||||
await refreshQueueState();
|
||||
if (!isOfflineNow()) scheduleFlushAll(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-2" data-testid="offline-outbox">
|
||||
<h4 className="text-sm font-medium text-foreground">{t('title')}</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('description')}</p>
|
||||
{rows.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground" data-testid="offline-outbox-empty">{t('empty')}</p>
|
||||
) : (
|
||||
<ul className="mt-3 divide-y divide-border rounded-md border border-border" aria-label={t('title')}>
|
||||
{rows.map((row) => (
|
||||
<li key={row.id} className="flex flex-col gap-2 px-3 py-2.5 sm:flex-row sm:items-start sm:justify-between" data-testid="offline-outbox-row" data-status={row.status}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">{row.subject || t('no_subject')}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{t('to', { recipients: row.to.join(', ') })}
|
||||
{several && <> · {accounts.find((a) => a.id === row.account)?.label ?? row.account}</>}
|
||||
</p>
|
||||
<p className={row.status === 'failed' || row.status === 'uncertain' ? 'text-xs text-destructive [overflow-wrap:anywhere]' : 'text-xs text-muted-foreground'}>
|
||||
{status(row)} · {format.dateTime(row.createdAt, { dateStyle: 'short', timeStyle: 'short' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{(row.status === 'failed' || row.status === 'uncertain') && (
|
||||
<Button variant="outline" size="sm" onClick={() => void retry(row)}>{t('retry')}</Button>
|
||||
)}
|
||||
{row.status !== 'sending' && (
|
||||
<Button variant="outline" size="sm" onClick={() => setDeleting(row)}>{t('delete')}</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{changes > 0 && (
|
||||
<p className="mt-2 text-xs text-muted-foreground" data-testid="offline-pending-changes">{t('changes', { count: changes })}</p>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleting}
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={() => {
|
||||
const row = deleting;
|
||||
setDeleting(null);
|
||||
if (row) void deleteOutboxEntry(row.id).then(refreshQueueState);
|
||||
}}
|
||||
title={t('delete_confirm_title')}
|
||||
message={t('delete_confirm_message')}
|
||||
confirmText={t('delete')}
|
||||
variant="destructive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { PluginSlot } from '@/components/plugins/plugin-slot';
|
||||
import { OrgAnnouncement } from '@/components/org/org-announcement';
|
||||
import { OfflineBanner } from '@/components/offline/offline-banner';
|
||||
import { useIsEmbedded } from '@/hooks/use-is-embedded';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
@@ -25,6 +26,7 @@ export function AppTopBannerSlot() {
|
||||
return (
|
||||
<>
|
||||
{!embedded && <OrgAnnouncement />}
|
||||
{!embedded && <OfflineBanner />}
|
||||
<PluginSlot name="app-top-banner" extraProps={{ username, serverUrl, impersonated }} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { DebugSettings } from './debug-settings';
|
||||
import { MailBackupSettings } from '@/components/mail-transfer/mail-backup-settings';
|
||||
import { OfflineSettings } from '@/components/offline/offline-settings';
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||
@@ -217,6 +218,8 @@ export function AboutDataSettings() {
|
||||
|
||||
<MailBackupSettings />
|
||||
|
||||
<OfflineSettings />
|
||||
|
||||
<SettingsSection id="fix" title={t('sections.fix')} description={t('sections.fix_description')}>
|
||||
<SettingItem label={t('refresh_cache.label')} description={t('refresh_cache.description')} term="cache">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowRefreshConfirm(true)}>
|
||||
|
||||
@@ -29,7 +29,7 @@ export class FakeCaches {
|
||||
}
|
||||
}
|
||||
|
||||
export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: Array<{ id: string }> }) {
|
||||
export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: Array<{ id: string }>; fetch?: (input: unknown) => Promise<Response> }) {
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
let skipped = 0;
|
||||
const self = {
|
||||
@@ -39,12 +39,18 @@ export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients:
|
||||
clients: { claim: async () => {}, matchAll: async () => opts.clients },
|
||||
registration: { showNotification: async () => {}, getNotifications: async () => [] },
|
||||
};
|
||||
const context = vm.createContext({ self, caches: opts.caches, fetch: async () => { throw new TypeError('offline'); }, URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout });
|
||||
const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout });
|
||||
vm.runInContext(SW_SOURCE, context);
|
||||
async function dispatch(type: string, event: Record<string, unknown> = {}) {
|
||||
const waits: Promise<unknown>[] = [];
|
||||
listeners.get(type)?.({ ...event, waitUntil: (p: Promise<unknown>) => waits.push(p) });
|
||||
await Promise.all(waits);
|
||||
}
|
||||
return { dispatch, listeners, skipped: () => skipped };
|
||||
/** A fetch event: what the worker answered, or null when it left the request to the network. */
|
||||
async function request(req: { url: string; method?: string; mode?: string }): Promise<Response | null> {
|
||||
let answer: Promise<Response> | null = null;
|
||||
listeners.get('fetch')?.({ request: { method: 'GET', mode: 'cors', ...req }, respondWith: (p: Promise<Response>) => { answer = p; } });
|
||||
return answer ? await answer : null;
|
||||
}
|
||||
return { dispatch, listeners, request, skipped: () => skipped };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { FakeCaches, loadWorker } from './sw-harness';
|
||||
|
||||
// Offline mode's app shell in the service worker (#523): nothing is cached
|
||||
// or answered while it is off; while it is on, pages are network first and
|
||||
// kept in this build's cache, chunks come from any build's cache first.
|
||||
|
||||
const ORIGIN = 'https://mail.example.com';
|
||||
|
||||
function sameOrigin(body: string, type = 'text/html') {
|
||||
const res = new Response(body, { status: 200, headers: { 'Content-Type': type } });
|
||||
Object.defineProperty(res, 'type', { value: 'basic' });
|
||||
return res;
|
||||
}
|
||||
|
||||
async function shellOn(caches: FakeCaches, on: boolean) {
|
||||
const cache = await caches.open('bulwark-sw-state-v1');
|
||||
await cache.put('/__bulwark/offline-shell', new Response(JSON.stringify({ on })));
|
||||
}
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
describe('service worker with offline mode off', () => {
|
||||
it('leaves every request to the network and caches nothing', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, false);
|
||||
const fetchMock = vi.fn(async () => sameOrigin('<html>'));
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock });
|
||||
await settle();
|
||||
expect(await worker.request({ url: `${ORIGIN}/mail`, mode: 'navigate' })).toBeNull();
|
||||
expect(await worker.request({ url: `${ORIGIN}/_next/static/chunks/app.js` })).toBeNull();
|
||||
expect((await caches.keys()).filter((k) => k.startsWith('bulwark-shell-'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('also when nothing was ever stored', async () => {
|
||||
const caches = new FakeCaches();
|
||||
const fetchMock = vi.fn(async () => sameOrigin('<html>'));
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock });
|
||||
await settle();
|
||||
expect(await worker.request({ url: `${ORIGIN}/mail`, mode: 'navigate' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('service worker with offline mode on', () => {
|
||||
it('keeps the pages it served and serves them when the network fails', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, true);
|
||||
let online = true;
|
||||
const fetchMock = vi.fn(async () => {
|
||||
if (!online) throw new TypeError('Failed to fetch');
|
||||
return sameOrigin('<html>mail page</html>');
|
||||
});
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock });
|
||||
await settle();
|
||||
const first = await worker.request({ url: `${ORIGIN}/mail`, mode: 'navigate' });
|
||||
expect(await first!.text()).toBe('<html>mail page</html>');
|
||||
await settle();
|
||||
expect(await caches.has('bulwark-shell-a')).toBe(true);
|
||||
|
||||
online = false;
|
||||
const offline = await worker.request({ url: `${ORIGIN}/mail`, mode: 'navigate' });
|
||||
expect(await offline!.text()).toBe('<html>mail page</html>');
|
||||
// A deeper page of the same section falls back to the section's page.
|
||||
const deep = await worker.request({ url: `${ORIGIN}/mail/inbox/abc`, mode: 'navigate' });
|
||||
expect(await deep!.text()).toBe('<html>mail page</html>');
|
||||
});
|
||||
|
||||
it('serves a chunk of an older build from that build\'s cache, and keeps new ones per build', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, true);
|
||||
const old = await caches.open('bulwark-shell-a');
|
||||
await old.put(`${ORIGIN}/_next/static/chunks/old.js`, sameOrigin('old chunk', 'text/javascript'));
|
||||
const fetchMock = vi.fn(async () => sameOrigin('new chunk', 'text/javascript'));
|
||||
const worker = loadWorker({ buildId: 'b', caches, clients: [], fetch: fetchMock });
|
||||
await settle();
|
||||
const hit = await worker.request({ url: `${ORIGIN}/_next/static/chunks/old.js` });
|
||||
expect(await hit!.text()).toBe('old chunk');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await worker.request({ url: `${ORIGIN}/_next/static/chunks/new.js` });
|
||||
await settle();
|
||||
const own = await caches.open('bulwark-shell-b');
|
||||
expect(await own.match(`${ORIGIN}/_next/static/chunks/new.js`)).toBeDefined();
|
||||
expect(await old.match(`${ORIGIN}/_next/static/chunks/new.js`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never answers the API, the admin pages or other origins', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, true);
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: vi.fn(async () => sameOrigin('x')) });
|
||||
await settle();
|
||||
expect(await worker.request({ url: `${ORIGIN}/api/admin/policy` })).toBeNull();
|
||||
expect(await worker.request({ url: `${ORIGIN}/admin/login`, mode: 'navigate' })).toBeNull();
|
||||
expect(await worker.request({ url: 'https://stalwart.example.com/jmap' })).toBeNull();
|
||||
expect(await worker.request({ url: `${ORIGIN}/mail`, method: 'POST', mode: 'navigate' })).toBeNull();
|
||||
});
|
||||
|
||||
it('waits for the old build\'s tabs before taking over', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, true);
|
||||
const worker = loadWorker({ buildId: 'b', caches, clients: [{ id: 'tab-1' }] });
|
||||
const before = worker.skipped();
|
||||
await worker.dispatch('install');
|
||||
expect(worker.skipped()).toBe(before);
|
||||
// Without offline mode it takes over at once, as before.
|
||||
const plain = new FakeCaches();
|
||||
const other = loadWorker({ buildId: 'b', caches: plain, clients: [{ id: 'tab-1' }] });
|
||||
await other.dispatch('install');
|
||||
expect(other.skipped()).toBe(1);
|
||||
});
|
||||
|
||||
it('precaches the shell a tab names, and drops every shell when turned off', async () => {
|
||||
const caches = new FakeCaches();
|
||||
const fetchMock = vi.fn(async (url: unknown) => sameOrigin(`file ${String(url)}`, 'text/javascript'));
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock });
|
||||
await worker.dispatch('message', { data: { type: 'offline-shell', on: true, urls: ['/_next/static/chunks/a.js', '/mail', 'https://evil.example/x.js'] } });
|
||||
const shell = await caches.open('bulwark-shell-a');
|
||||
expect(await shell.match(`${ORIGIN}/_next/static/chunks/a.js`)).toBeDefined();
|
||||
expect(await shell.match(`${ORIGIN}/mail`)).toBeDefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
await worker.dispatch('message', { data: { type: 'offline-shell', on: false } });
|
||||
expect((await caches.keys()).filter((k) => k.startsWith('bulwark-shell-'))).toEqual([]);
|
||||
expect(await worker.request({ url: `${ORIGIN}/mail`, mode: 'navigate' })).toBeNull();
|
||||
});
|
||||
});
|
||||
+161
-3
@@ -40,6 +40,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 { toInertBlob } from "@/lib/file-preview";
|
||||
import { parseAuthenticationResults } from "@/lib/email-headers";
|
||||
import { IS_LITE_STALWART } from "@/lib/lite";
|
||||
@@ -1587,6 +1588,14 @@ export class JMAPClient implements IJMAPClient {
|
||||
// lazy default-calendar/address-book creation can't run twice (#907).
|
||||
private firstTouchGate = new FirstTouchGate();
|
||||
|
||||
/**
|
||||
* Started without the server (#523): the session comes from the copy kept
|
||||
* with the offline data, every request is answered from this device, and
|
||||
* nothing goes to the network. Replaced by a real client once the server
|
||||
* can be reached again (a reload signs in as usual).
|
||||
*/
|
||||
private offlineOnly = false;
|
||||
|
||||
constructor(serverUrl: string, username: string, password: string) {
|
||||
this.serverUrl = serverUrl.replace(/\/$/, '');
|
||||
this.username = username;
|
||||
@@ -1594,6 +1603,30 @@ export class JMAPClient implements IJMAPClient {
|
||||
this.authHeader = basicAuthHeader(username, password);
|
||||
}
|
||||
|
||||
/** A client that answers from this device only, from a session kept with the offline data (#523). */
|
||||
static fromOfflineSession(serverUrl: string, username: string, session: unknown): JMAPClient {
|
||||
const client = new JMAPClient(serverUrl, username, '');
|
||||
client.offlineOnly = true;
|
||||
const snapshot = session as JMAPSession;
|
||||
client.session = snapshot;
|
||||
client.capabilities = snapshot.capabilities || {};
|
||||
client.apiUrl = snapshot.apiUrl;
|
||||
client.downloadUrl = snapshot.downloadUrl;
|
||||
client.accounts = snapshot.accounts || {};
|
||||
client.accountId = snapshot.primaryAccounts?.["urn:ietf:params:jmap:mail"] || Object.keys(client.accounts)[0] || '';
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Started offline from the device's copy of the session (fromOfflineSession). */
|
||||
isOfflineOnly(): boolean {
|
||||
return this.offlineOnly;
|
||||
}
|
||||
|
||||
/** The session resource, for offline mode to keep (it names the accounts and URLs, no credentials). */
|
||||
getSessionSnapshot(): unknown {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
static withBearer(
|
||||
serverUrl: string,
|
||||
accessToken: string,
|
||||
@@ -1902,6 +1935,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.offlineOnly) throw new OfflineUnavailableError();
|
||||
this.intentionallyDisconnected = false;
|
||||
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
|
||||
|
||||
@@ -2107,6 +2141,35 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (!this.apiUrl) {
|
||||
throw new Error('Not connected. Call connect() first.');
|
||||
}
|
||||
// Offline mode (#523, lib/offline/): answered from this device when the
|
||||
// browser is offline or the network fails, and every answer from the
|
||||
// server goes to the device's cache. Without offline mode no hooks are
|
||||
// set and this is the plain network request.
|
||||
const offline = getOfflineHooks();
|
||||
if (this.offlineOnly || offline?.skipNetwork(this)) {
|
||||
const local = offline ? await offline.answer(this, methodCalls) : null;
|
||||
if (local) return local as JMAPResponse;
|
||||
throw new OfflineUnavailableError();
|
||||
}
|
||||
let answer: JMAPResponse;
|
||||
try {
|
||||
answer = await this.networkRequest(methodCalls, using, opts);
|
||||
} catch (error) {
|
||||
if (offline && isNetworkFailure(error)) {
|
||||
offline.noteNetwork(this, false);
|
||||
const local = await offline.answer(this, methodCalls);
|
||||
if (local) return local as JMAPResponse;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (offline) {
|
||||
offline.noteNetwork(this, true);
|
||||
offline.record(this, methodCalls, answer);
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
private async networkRequest(methodCalls: JMAPMethodCall[], using?: string[], opts?: { transfer?: boolean }): Promise<JMAPResponse> {
|
||||
|
||||
const requestBody = {
|
||||
using: using || (hasSubmissionMethod(methodCalls) ? [...SUBMISSION_USING] : ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"]),
|
||||
@@ -3022,6 +3085,60 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Offline mode (#523, lib/offline/sync.ts) ----
|
||||
|
||||
/** Ids of a folder's messages received since `sinceIso`, newest first. */
|
||||
async getRecentEmailIds(mailboxId: string, accountId: string | undefined, sinceIso: string, limit: number): Promise<string[]> {
|
||||
const response = await this.request([
|
||||
["Email/query", {
|
||||
accountId: accountId || this.accountId,
|
||||
filter: { inMailbox: mailboxId, after: sinceIso },
|
||||
sort: [{ property: "receivedAt", isAscending: false }],
|
||||
limit,
|
||||
}, "0"],
|
||||
]);
|
||||
const [name, result] = response.methodResponses?.[0] ?? [];
|
||||
if (name !== "Email/query") throw new Error(methodErrorMessage(response, 'Failed to query emails'));
|
||||
return (result?.ids ?? []) as string[];
|
||||
}
|
||||
|
||||
/** Messages with everything the reader shows (the getEmail properties), a batch at a time. */
|
||||
async getFullEmails(emailIds: string[], accountId?: string): Promise<Email[]> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const out: Email[] = [];
|
||||
for (const batchIds of batched(emailIds, 20)) {
|
||||
const response = await this.request([
|
||||
["Email/get", {
|
||||
accountId: targetAccountId,
|
||||
ids: batchIds,
|
||||
properties: [
|
||||
"id", "threadId", "mailboxIds", "keywords", "size",
|
||||
"receivedAt", "sentAt", "from", "to", "cc", "bcc", "replyTo",
|
||||
"subject", "preview", "textBody", "htmlBody", "bodyValues",
|
||||
"hasAttachment", "attachments", "messageId", "inReplyTo",
|
||||
"references", "headers", "bodyStructure", "blobId",
|
||||
],
|
||||
fetchTextBodyValues: true,
|
||||
fetchHTMLBodyValues: true,
|
||||
fetchAllBodyValues: true,
|
||||
maxBodyValueBytes: OPEN_EMAIL_MAX_BODY_BYTES,
|
||||
}, "0"],
|
||||
]);
|
||||
out.push(...((response.methodResponses?.[0]?.[1]?.list ?? []) as Email[]));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Changes made offline, sent as they were made: the ids the server refused. */
|
||||
async applyEmailPatches(accountId: string | undefined, update: Record<string, Record<string, unknown>>): Promise<string[]> {
|
||||
const response = await this.request([
|
||||
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
|
||||
]);
|
||||
const [name, result] = response.methodResponses?.[0] ?? [];
|
||||
if (name !== "Email/set") throw jmapError(methodErrorMessage(response, 'Failed to update emails'), result);
|
||||
return Object.keys((result?.notUpdated ?? {}) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
// The 256000-byte body cap keeps list/thread fetches fast for the common
|
||||
// case, but truncation lands mid-tag on large HTML bodies (e.g. a data:image
|
||||
// src cut in half), which drops the whole element and can render blank. Only
|
||||
@@ -5938,6 +6055,15 @@ export class JMAPClient implements IJMAPClient {
|
||||
const targetAccountId = opts.accountId || this.accountId;
|
||||
const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(targetAccountId));
|
||||
|
||||
// Offline (#523): the file stays on this device under a stand-in id and
|
||||
// is uploaded when the message it belongs to is sent from the outbox.
|
||||
const offline = getOfflineHooks();
|
||||
if (this.offlineOnly || offline?.skipNetwork(this)) {
|
||||
const held = offline ? await offline.holdUpload(this, file, targetAccountId) : null;
|
||||
if (held) return held;
|
||||
throw new OfflineUnavailableError();
|
||||
}
|
||||
|
||||
// XHR path: fetch() does not expose upload progress events, so when the
|
||||
// caller wants progress (or an AbortSignal) we use XMLHttpRequest. The
|
||||
// fetch path is kept for callers that don't need either, to preserve
|
||||
@@ -6121,11 +6247,37 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
async fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<Blob> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
|
||||
const response = await this.authenticatedFetch(url, {}, { timeoutMs: JMAPClient.TRANSFER_TIMEOUT_MS });
|
||||
const response = await this.offlineAwareDownload(url, blobId, accountId);
|
||||
if (response instanceof Blob) return response;
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||
}
|
||||
return response.blob();
|
||||
const blob = await response.blob();
|
||||
getOfflineHooks()?.recordBlob(this, blobId, accountId || this.accountId, blob);
|
||||
return blob;
|
||||
}
|
||||
|
||||
/**
|
||||
* A blob download, or the copy kept on this device (#523) when the
|
||||
* browser is offline or the network fails.
|
||||
*/
|
||||
private async offlineAwareDownload(url: string, blobId: string, accountId: string | undefined): Promise<Response | Blob> {
|
||||
const offline = getOfflineHooks();
|
||||
const account = accountId || this.accountId;
|
||||
if (this.offlineOnly || offline?.skipNetwork(this)) {
|
||||
const local = offline ? await offline.blob(this, blobId, account) : null;
|
||||
if (local) return local;
|
||||
throw new OfflineUnavailableError();
|
||||
}
|
||||
try {
|
||||
return await this.authenticatedFetch(url, {}, { timeoutMs: JMAPClient.TRANSFER_TIMEOUT_MS });
|
||||
} catch (error) {
|
||||
if (offline && isNetworkFailure(error)) {
|
||||
const local = await offline.blob(this, blobId, account);
|
||||
if (local) return local;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -10594,6 +10746,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
private static readonly SSE_LIVE_WINDOW = 45_000;
|
||||
|
||||
setupPushNotifications(): boolean {
|
||||
// Nothing to listen to without the server.
|
||||
if (this.offlineOnly) return false;
|
||||
const eventSourceUrl = this.getEventSourceUrl();
|
||||
if (eventSourceUrl) {
|
||||
this.connectSSE(eventSourceUrl);
|
||||
@@ -11145,7 +11299,11 @@ export class JMAPClient implements IJMAPClient {
|
||||
/** Fetch blob content as an ArrayBuffer (for S/MIME byte processing). */
|
||||
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string, rangeHeader?: number): Promise<ArrayBuffer> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
|
||||
const response = await this.authenticatedFetch(url, { }, { timeoutMs: JMAPClient.TRANSFER_TIMEOUT_MS });
|
||||
const response = await this.offlineAwareDownload(url, blobId, accountId);
|
||||
if (response instanceof Blob) {
|
||||
const bytes = await response.arrayBuffer();
|
||||
return rangeHeader ? bytes.slice(0, rangeHeader) : bytes;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Where the JMAP client hands over to offline mode (#523, lib/offline/).
|
||||
* The client knows nothing about the cache: it asks these hooks whether to
|
||||
* skip the network, gives them what the server answered, and asks them for
|
||||
* an answer when the network is gone. Nothing is registered while offline
|
||||
* mode is off for every account, so the client then behaves as before.
|
||||
*
|
||||
* Kept on globalThis: Next can bundle the client module more than once, and
|
||||
* every copy has to find the same hooks.
|
||||
*/
|
||||
|
||||
export type OfflineMethodCall = [string, Record<string, unknown>, string];
|
||||
export interface OfflineResponse {
|
||||
// Shaped like the server's answer (any per method, as the client reads it).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
methodResponses: Array<[string, any, string]>;
|
||||
}
|
||||
|
||||
/** What the hooks need to know about the client asking. */
|
||||
export interface OfflineClientRef {
|
||||
/** Server URL and login, to find the Bulwark account. */
|
||||
getServerUrl(): string;
|
||||
getUsername(): string;
|
||||
getAccountId(): string;
|
||||
}
|
||||
|
||||
export interface OfflineHooks {
|
||||
/** Answer without trying the network: the browser says it is offline (or the client was started offline). */
|
||||
skipNetwork(client: OfflineClientRef): boolean;
|
||||
/** An answer from the device for a request the network couldn't take, or null. */
|
||||
answer(client: OfflineClientRef, methodCalls: OfflineMethodCall[]): Promise<OfflineResponse | null>;
|
||||
/** What the server answered, for the cache. */
|
||||
record(client: OfflineClientRef, methodCalls: OfflineMethodCall[], response: OfflineResponse): void;
|
||||
/** A request reached the server (true) or failed on the network (false). */
|
||||
noteNetwork(client: OfflineClientRef, reachable: boolean): void;
|
||||
/** A blob kept on the device, or null. */
|
||||
blob(client: OfflineClientRef, blobId: string, accountId: string): Promise<Blob | null>;
|
||||
/** A blob the server sent, for the cache. */
|
||||
recordBlob(client: OfflineClientRef, blobId: string, accountId: string, blob: Blob): void;
|
||||
/** Keep a file to upload later; the stand-in blob id, or null when offline mode doesn't cover the client. */
|
||||
holdUpload(client: OfflineClientRef, file: File, accountId: string): Promise<{ blobId: string; size: number; type: string } | null>;
|
||||
}
|
||||
|
||||
const KEY = '__bulwarkOfflineHooks';
|
||||
|
||||
type HookGlobal = typeof globalThis & { [KEY]?: OfflineHooks | null };
|
||||
|
||||
export function setOfflineHooks(hooks: OfflineHooks | null): void {
|
||||
(globalThis as HookGlobal)[KEY] = hooks;
|
||||
}
|
||||
|
||||
export function getOfflineHooks(): OfflineHooks | null {
|
||||
return (globalThis as HookGlobal)[KEY] ?? null;
|
||||
}
|
||||
|
||||
/** The network refused the request (not an HTTP error): offline, DNS, a dropped connection. */
|
||||
export function isNetworkFailure(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
if (error.name === 'OfflineUnavailableError') return true;
|
||||
return error.name === 'TypeError' && /fetch|network|load failed|internet/i.test(error.message);
|
||||
}
|
||||
|
||||
/** Thrown for a request the device can't answer while the server can't be reached. */
|
||||
export class OfflineUnavailableError extends Error {
|
||||
constructor(message = 'Not available offline') {
|
||||
super(message);
|
||||
this.name = 'OfflineUnavailableError';
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,11 @@ export interface Email {
|
||||
|
||||
export interface SendEmailResult {
|
||||
scheduled: boolean;
|
||||
/**
|
||||
* Kept in the offline outbox (#523), not sent yet: it goes out once the
|
||||
* server can be reached again. Nothing else is set then.
|
||||
*/
|
||||
queued?: boolean;
|
||||
emailId?: string;
|
||||
emailSubmissionId?: string;
|
||||
sendAt?: string;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
cacheUsage,
|
||||
emailIndex,
|
||||
emailKey,
|
||||
enforceCap,
|
||||
forgetIndexes,
|
||||
getBlob,
|
||||
getBody,
|
||||
getMisc,
|
||||
patchEmails,
|
||||
putBlob,
|
||||
putEmails,
|
||||
putMisc,
|
||||
wipeAccountCache,
|
||||
} from '../cache';
|
||||
import { forgetDeviceKey, openJson, sealJson } from '../crypto';
|
||||
import { deleteOfflineDb, idbGet, idbGetAll } from '../db';
|
||||
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
const email = (id: string, ageDays: number, extra: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: {},
|
||||
receivedAt: new Date(Date.now() - ageDays * day).toISOString(),
|
||||
subject: `Subject ${id}`,
|
||||
...extra,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
forgetIndexes();
|
||||
forgetDeviceKey();
|
||||
await deleteOfflineDb();
|
||||
});
|
||||
|
||||
describe('offline encryption', () => {
|
||||
it('keeps nothing readable and a key that cannot be exported', async () => {
|
||||
const sealed = await sealJson({ subject: 'Quarterly numbers' });
|
||||
expect(new TextDecoder().decode(sealed.data)).not.toContain('Quarterly');
|
||||
expect(await openJson(sealed)).toEqual({ subject: 'Quarterly numbers' });
|
||||
const stored = await idbGet<{ key: CryptoKey }>('keys', 'aes');
|
||||
expect(stored?.key.extractable).toBe(false);
|
||||
await expect(crypto.subtle.exportKey('raw', stored!.key)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('stores messages encrypted, list part and body apart', async () => {
|
||||
await putEmails('acc', 'j1', [email('m1', 1, { bodyValues: { 1: { value: 'Secret text' } }, textBody: [{ partId: '1' }] })]);
|
||||
const records = await idbGetAll<{ data: ArrayBuffer }>('emails');
|
||||
expect(records).toHaveLength(1);
|
||||
expect(new TextDecoder().decode(records[0].data)).not.toContain('Subject m1');
|
||||
const bodies = await idbGetAll<{ data: ArrayBuffer }>('bodies');
|
||||
expect(new TextDecoder().decode(bodies[0].data)).not.toContain('Secret text');
|
||||
|
||||
forgetIndexes();
|
||||
const index = await emailIndex('acc');
|
||||
const meta = index.get(emailKey('acc', 'j1', 'm1'))!;
|
||||
expect(meta.subject).toBe('Subject m1');
|
||||
expect(meta).not.toHaveProperty('bodyValues');
|
||||
expect((await getBody('acc', 'j1', 'm1'))?.bodyValues).toEqual({ 1: { value: 'Secret text' } });
|
||||
});
|
||||
|
||||
it('merges a later list fetch into the kept message', async () => {
|
||||
await putEmails('acc', 'j1', [email('m1', 1, { bodyValues: { 1: { value: 'x' } } })]);
|
||||
await putEmails('acc', 'j1', [{ id: 'm1', keywords: { $seen: true } }]);
|
||||
const meta = (await emailIndex('acc')).get(emailKey('acc', 'j1', 'm1'))!;
|
||||
expect(meta.keywords).toEqual({ $seen: true });
|
||||
expect(meta.subject).toBe('Subject m1');
|
||||
expect(await getBody('acc', 'j1', 'm1')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('applies keyword and folder patches', async () => {
|
||||
await putEmails('acc', 'j1', [email('m1', 1)]);
|
||||
await patchEmails('acc', 'j1', { m1: { 'keywords/$flagged': true, 'mailboxIds/inbox': null, 'mailboxIds/archive': true } });
|
||||
const meta = (await emailIndex('acc')).get(emailKey('acc', 'j1', 'm1'))!;
|
||||
expect(meta.keywords).toEqual({ $flagged: true });
|
||||
expect(meta.mailboxIds).toEqual({ archive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the cache cap', () => {
|
||||
it('drops the oldest messages first, with their bodies and attachments', async () => {
|
||||
await putEmails('acc', 'j1', [
|
||||
email('old', 20, { bodyValues: { 1: { value: 'a'.repeat(4000) } } }),
|
||||
email('mid', 10, { bodyValues: { 1: { value: 'b'.repeat(4000) } } }),
|
||||
email('new', 1, { bodyValues: { 1: { value: 'c'.repeat(4000) } } }),
|
||||
]);
|
||||
await putBlob('acc', 'j1', 'blob-old', new Blob(['x'.repeat(4000)]), Date.now() - 20 * day, emailKey('acc', 'j1', 'old'));
|
||||
const { total } = await cacheUsage();
|
||||
// Room for about two messages.
|
||||
const freed = await enforceCap(total - 6000);
|
||||
expect(freed).toBeGreaterThan(0);
|
||||
const index = await emailIndex('acc');
|
||||
expect(index.has(emailKey('acc', 'j1', 'old'))).toBe(false);
|
||||
expect(index.has(emailKey('acc', 'j1', 'new'))).toBe(true);
|
||||
expect(await getBody('acc', 'j1', 'old')).toBeNull();
|
||||
expect(await getBlob('acc', 'j1', 'blob-old')).toBeNull();
|
||||
expect(await getBody('acc', 'j1', 'new')).not.toBeNull();
|
||||
expect((await cacheUsage()).total).toBeLessThanOrEqual(total - 6000);
|
||||
});
|
||||
|
||||
it('leaves the cache alone under the cap', async () => {
|
||||
await putEmails('acc', 'j1', [email('m1', 1)]);
|
||||
expect(await enforceCap()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wiping one account', () => {
|
||||
it('removes that account only', async () => {
|
||||
await putEmails('a', 'j1', [email('m1', 1)]);
|
||||
await putEmails('b', 'j1', [email('m2', 1)]);
|
||||
await putMisc('a', 'session', { apiUrl: 'x' });
|
||||
await wipeAccountCache('a');
|
||||
expect((await emailIndex('a')).size).toBe(0);
|
||||
expect(await getMisc('a', 'session')).toBeNull();
|
||||
forgetIndexes();
|
||||
expect((await emailIndex('b')).size).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
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 { passSignOutGates, addSignOutGate, purgeSignedOutData } from '@/lib/sign-out-cleanup';
|
||||
import { enforcePolicy, startOfflineMode, wipeEverything } from '../offline-mode';
|
||||
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 { useOfflineStore, writeEnabled, OFFLINE_ACCOUNTS_KEY } from '../state';
|
||||
import type { JMAPClient } from '@/lib/jmap/client';
|
||||
|
||||
function fakeClient(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
getServerUrl: () => 'https://mail.example.org',
|
||||
getUsername: () => 'alice@example.org',
|
||||
getAccountId: () => 'j1',
|
||||
isOfflineOnly: () => false,
|
||||
getSessionSnapshot: () => ({ apiUrl: 'https://mail.example.org/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 () => {}),
|
||||
getMailboxes: vi.fn(async () => [{ id: 'sent', role: 'sent' }, { id: 'drafts', role: 'drafts' }]),
|
||||
sendRawEmail: vi.fn(async () => ({ scheduled: false })),
|
||||
submitRawEmail: vi.fn(async () => ({ scheduled: false })),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setOnline(online: boolean) {
|
||||
Object.defineProperty(window.navigator, 'onLine', { configurable: true, get: () => online });
|
||||
window.dispatchEvent(new Event(online ? 'online' : 'offline'));
|
||||
}
|
||||
|
||||
let stop: () => void = () => {};
|
||||
|
||||
beforeEach(async () => {
|
||||
forgetIndexes();
|
||||
forgetDeviceKey();
|
||||
await deleteOfflineDb();
|
||||
localStorage.clear();
|
||||
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, browserOffline: false, networkDown: false, 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] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
stop();
|
||||
setOnline(true);
|
||||
await wipeEverything();
|
||||
});
|
||||
|
||||
const sendArgs = (client: unknown, options: Record<string, unknown>) => useEmailStore.getState().sendEmail(
|
||||
client as never, ['bob@example.org'], 'Quarterly numbers', 'Hi Bob', undefined, undefined, 'id-1', 'alice@example.org',
|
||||
'draft-1', 'Alice', '<p>Hi Bob</p>', [{ blobId: 'blob-1', name: 'a.txt', type: 'text/plain', size: 3 }], undefined, undefined, undefined, undefined, options as never,
|
||||
);
|
||||
|
||||
describe('the outbox', () => {
|
||||
it('keeps a composer send while offline, with its label, and sends nothing', async () => {
|
||||
const client = fakeClient();
|
||||
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
|
||||
writeEnabled(['acc']);
|
||||
stop = startOfflineMode();
|
||||
setOnline(false);
|
||||
|
||||
const result = await sendArgs(client, { outbox: true, labelHeaders: { 'header:SIO-Label:asRaw': ' marking="CONFIDENTIAL"' } });
|
||||
expect(result.queued).toBe(true);
|
||||
expect(client.sendEmail).not.toHaveBeenCalled();
|
||||
const { outbox } = await readQueue('acc');
|
||||
expect(outbox).toHaveLength(1);
|
||||
expect(outbox[0].summary).toEqual({ subject: 'Quarterly numbers', to: ['bob@example.org'] });
|
||||
expect(outbox[0].item).toMatchObject({ kind: 'email' });
|
||||
const options = (outbox[0].item as { args: unknown[] }).args[15] as Record<string, unknown>;
|
||||
expect(options.labelHeaders).toEqual({ 'header:SIO-Label:asRaw': ' marking="CONFIDENTIAL"' });
|
||||
expect(options).not.toHaveProperty('outbox');
|
||||
});
|
||||
|
||||
it('leaves sends from elsewhere (plugin forward, phishing report) to the network', async () => {
|
||||
const client = fakeClient({ sendEmail: vi.fn(async () => { throw new TypeError('Failed to fetch'); }) });
|
||||
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
|
||||
writeEnabled(['acc']);
|
||||
stop = startOfflineMode();
|
||||
setOnline(false);
|
||||
await expect(sendArgs(client, {})).rejects.toThrow('Failed to fetch');
|
||||
expect((await readQueue('acc')).outbox).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sends normally while online, and without offline mode', async () => {
|
||||
const client = fakeClient();
|
||||
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
|
||||
stop = startOfflineMode();
|
||||
setOnline(false);
|
||||
// Offline mode is off for this account: nothing is kept.
|
||||
await sendArgs(client, { outbox: true });
|
||||
expect(client.sendEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends what waited in order once the server answers: changes first, then mail', async () => {
|
||||
const order: string[] = [];
|
||||
const client = fakeClient({
|
||||
applyEmailPatches: vi.fn(async () => { order.push('patch'); return []; }),
|
||||
sendEmail: vi.fn(async () => { order.push('send'); return { scheduled: false }; }),
|
||||
});
|
||||
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
|
||||
writeEnabled(['acc']);
|
||||
stop = startOfflineMode();
|
||||
setOnline(false);
|
||||
await queuePatch('acc', 'j1', { m1: { 'keywords/$seen': true } });
|
||||
const held = await holdFile('acc', new File(['hello'], 'note.txt', { type: 'text/plain' }), 'note.txt');
|
||||
await useEmailStore.getState().sendEmail(client as never, ['bob@example.org'], 'With file', 'x', undefined, undefined, 'id-1', 'alice@example.org', undefined, 'Alice', undefined,
|
||||
[{ blobId: held.blobId, name: 'note.txt', type: 'text/plain', size: 5 }], undefined, undefined, new Date(Date.now() + 5000).toISOString(), undefined, { outbox: true } as never);
|
||||
|
||||
setOnline(true);
|
||||
const notify = vi.fn();
|
||||
await flushQueue('acc', client as unknown as JMAPClient, { notify, allowed: () => true });
|
||||
expect(order).toEqual(['patch', 'send']);
|
||||
expect(client.applyEmailPatches).toHaveBeenCalledWith('j1', { m1: { 'keywords/$seen': true } });
|
||||
const args = (client.sendEmail as ReturnType<typeof vi.fn>).mock.calls[0] as unknown[];
|
||||
expect(args[1]).toBe('With file');
|
||||
// The file attached offline was uploaded first; the passed undo hold was dropped.
|
||||
expect(args[10]).toEqual([{ blobId: 'server-note.txt', name: 'note.txt', type: 'text/plain', size: 5 }]);
|
||||
expect(args[13]).toBeUndefined();
|
||||
expect(notify).toHaveBeenCalledWith({ kind: 'sent', subject: 'With file' });
|
||||
const left = await readQueue('acc');
|
||||
expect(left.outbox).toHaveLength(0);
|
||||
expect(left.patches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps a send the server refused, marked failed, and one cut off on the way, marked uncertain', async () => {
|
||||
const client = fakeClient({ sendEmail: vi.fn(async () => { throw new Error('Recipient address rejected'); }) });
|
||||
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
|
||||
writeEnabled(['acc']);
|
||||
stop = startOfflineMode();
|
||||
setOnline(false);
|
||||
await sendArgs(client, { outbox: true });
|
||||
setOnline(true);
|
||||
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');
|
||||
|
||||
const cut = fakeClient({ sendEmail: vi.fn(async () => { throw new TypeError('Failed to fetch'); }) });
|
||||
await wipeEverything();
|
||||
writeEnabled(['acc']);
|
||||
useAuthStore.setState({ client: cut as never, activeAccountId: 'acc' });
|
||||
setOnline(false);
|
||||
await sendArgs(cut, { outbox: true });
|
||||
setOnline(true);
|
||||
await flushQueue('acc', cut as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
||||
({ outbox } = await readQueue('acc'));
|
||||
expect(outbox[0].status).toBe('uncertain');
|
||||
// Not tried again on its own.
|
||||
await flushQueue('acc', cut as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
||||
expect(cut.sendEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends nothing once the organisation forbids offline mode', async () => {
|
||||
const client = fakeClient();
|
||||
await queuePatch('acc', 'j1', { m1: { 'keywords/$seen': true } });
|
||||
await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => false });
|
||||
expect(client.applyEmailPatches).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('wiping offline data', () => {
|
||||
it('wipes an account when the organisation forbids offline mode', async () => {
|
||||
const client = fakeClient();
|
||||
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
|
||||
writeEnabled(['acc']);
|
||||
stop = startOfflineMode();
|
||||
await putEmails('acc', 'j1', [{ id: 'm1', mailboxIds: { inbox: true }, receivedAt: new Date().toISOString() }]);
|
||||
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);
|
||||
});
|
||||
expect(localStorage.getItem(OFFLINE_ACCOUNTS_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('wipes an account that signs out', async () => {
|
||||
writeEnabled(['acc', 'other']);
|
||||
stop = startOfflineMode();
|
||||
await putEmails('acc', 'j1', [{ id: 'm1', mailboxIds: { inbox: true }, receivedAt: new Date().toISOString() }]);
|
||||
await putEmails('other', 'j1', [{ id: 'm2', mailboxIds: { inbox: true }, receivedAt: new Date().toISOString() }]);
|
||||
useAccountStore.setState({ accounts: [{ id: 'other' } as never] });
|
||||
await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual(['other']));
|
||||
await vi.waitFor(async () => {
|
||||
forgetIndexes();
|
||||
expect((await emailIndex('acc')).size).toBe(0);
|
||||
});
|
||||
expect((await emailIndex('other')).size).toBe(1);
|
||||
});
|
||||
|
||||
it('deletes the database and the switch at a full sign-out', async () => {
|
||||
writeEnabled(['acc']);
|
||||
await putEmails('acc', 'j1', [{ id: 'm1', mailboxIds: { inbox: true }, receivedAt: new Date().toISOString() }]);
|
||||
forgetIndexes();
|
||||
purgeSignedOutData();
|
||||
expect(localStorage.getItem(OFFLINE_ACCOUNTS_KEY)).toBeNull();
|
||||
await vi.waitFor(async () => {
|
||||
const dbs = await indexedDB.databases();
|
||||
expect(dbs.map((d) => d.name)).not.toContain('bulwark-offline');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('signing out with unsent mail', () => {
|
||||
it('asks first, and a no keeps everyone signed in', async () => {
|
||||
const remove = addSignOutGate(async () => false);
|
||||
expect(await passSignOutGates('all')).toBe(false);
|
||||
remove();
|
||||
expect(await passSignOutGates('all')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
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';
|
||||
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
const at = (ageDays: number) => new Date(Date.now() - ageDays * day).toISOString();
|
||||
|
||||
let queued: Array<{ jmap: string; update: Record<string, Record<string, unknown>> }>;
|
||||
let refused: string[];
|
||||
const ctx: ResponderContext = {
|
||||
account: 'acc',
|
||||
defaultJmap: 'j1',
|
||||
queuePatch: async (jmap, update) => { queued.push({ jmap, update }); },
|
||||
onRefused: (what) => { refused.push(what); },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
forgetIndexes();
|
||||
forgetDeviceKey();
|
||||
await deleteOfflineDb();
|
||||
queued = [];
|
||||
refused = [];
|
||||
await putEmails('acc', 'j1', [
|
||||
{ id: 'a', threadId: 't1', mailboxIds: { inbox: true }, keywords: {}, receivedAt: at(3), subject: 'Older', bodyValues: { 1: { value: 'Body A' } }, textBody: [{ partId: '1' }] },
|
||||
{ id: 'b', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $flagged: true }, receivedAt: at(1), subject: 'Newer' },
|
||||
{ id: 'c', threadId: 't2', mailboxIds: { archive: true }, keywords: {}, receivedAt: at(2), subject: 'Archived' },
|
||||
]);
|
||||
await putMisc('acc', 'mailboxes', { j1: [{ id: 'inbox', name: 'Inbox', role: 'inbox' }, { id: 'archive', name: 'Archive', role: 'archive' }] });
|
||||
});
|
||||
|
||||
describe('answering a folder list offline', () => {
|
||||
it('answers the list request the client makes, back-references included', async () => {
|
||||
const response = await answerOffline(ctx, [
|
||||
['Email/query', { accountId: 'j1', filter: { inMailbox: 'inbox' }, sort: [{ property: 'receivedAt', isAscending: false }], limit: 50, position: 0, calculateTotal: true }, '0'],
|
||||
['Email/get', { accountId: 'j1', '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'threadId', 'subject', 'keywords'] }, '1'],
|
||||
['Thread/get', { accountId: 'j1', '#ids': { resultOf: '1', name: 'Email/get', path: '/list/*/threadId' } }, '2'],
|
||||
]);
|
||||
expect(response).not.toBeNull();
|
||||
const [query, get, thread] = response!.methodResponses;
|
||||
expect(query[1].ids).toEqual(['b', 'a']);
|
||||
expect(query[1].total).toBe(2);
|
||||
expect(get[1].list).toEqual([
|
||||
{ id: 'b', threadId: 't1', subject: 'Newer', keywords: { $flagged: true } },
|
||||
{ id: 'a', threadId: 't1', subject: 'Older', keywords: {} },
|
||||
]);
|
||||
expect(thread[1].list).toEqual([{ id: 't1', emailIds: ['a', 'b'] }]);
|
||||
});
|
||||
|
||||
it('pins flagged mail first when the list sorts that way', async () => {
|
||||
const response = await answerOffline(ctx, [
|
||||
['Email/query', { filter: { inMailbox: 'inbox' }, sort: [{ property: 'receivedAt', isAscending: true }] }, '0'],
|
||||
]);
|
||||
expect(response!.methodResponses[0][1].ids).toEqual(['a', 'b']);
|
||||
const pinned = await answerOffline(ctx, [
|
||||
['Email/query', { filter: { inMailbox: 'inbox' }, sort: [{ property: 'hasKeyword', keyword: '$flagged', isAscending: false }, { property: 'receivedAt', isAscending: true }] }, '0'],
|
||||
]);
|
||||
expect(pinned!.methodResponses[0][1].ids).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('opens a kept message with its body and refuses one without', async () => {
|
||||
const full = { properties: ['id', 'subject', 'bodyValues', 'textBody'], fetchTextBodyValues: true };
|
||||
const response = await answerOffline(ctx, [['Email/get', { ids: ['a', 'b'], ...full }, '0']]);
|
||||
const result = response!.methodResponses[0][1];
|
||||
expect(result.list).toEqual([{ id: 'a', subject: 'Older', bodyValues: { 1: { value: 'Body A' } }, textBody: [{ partId: '1' }] }]);
|
||||
expect(result.notFound).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('answers the folder list', async () => {
|
||||
const response = await answerOffline(ctx, [['Mailbox/get', { accountId: 'j1' }, '0']]);
|
||||
expect(response!.methodResponses[0][1].list.map((m: { id: string }) => m.id)).toEqual(['inbox', 'archive']);
|
||||
});
|
||||
|
||||
it('leaves search to the server', async () => {
|
||||
expect(await answerOffline(ctx, [['Email/query', { filter: { text: 'invoice' } }, '0']])).toBeNull();
|
||||
expect(await answerOffline(ctx, [['SearchSnippet/get', {}, '0']])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('changes made offline', () => {
|
||||
it('queues read, flag and move, and shows them at once', async () => {
|
||||
const response = await answerOffline(ctx, [
|
||||
['Email/set', { accountId: 'j1', update: { a: { 'keywords/$seen': true, 'keywords/$flagged': true }, c: { 'mailboxIds/archive': null, 'mailboxIds/inbox': true } } }, '0'],
|
||||
]);
|
||||
expect(response!.methodResponses[0][1].updated).toEqual({ a: null, c: null });
|
||||
expect(queued).toEqual([{ jmap: 'j1', update: { a: { 'keywords/$seen': true, 'keywords/$flagged': true }, c: { 'mailboxIds/archive': null, 'mailboxIds/inbox': true } } }]);
|
||||
const index = await emailIndex('acc');
|
||||
expect(index.get(emailKey('acc', 'j1', 'a'))?.keywords).toEqual({ $seen: true, $flagged: true });
|
||||
const inbox = await answerOffline(ctx, [['Email/query', { filter: { inMailbox: 'inbox' } }, '0']]);
|
||||
expect(inbox!.methodResponses[0][1].ids).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
it('refuses deleting for good and emptying a folder', async () => {
|
||||
expect(await answerOffline(ctx, [['Email/set', { destroy: ['a'] }, '0']])).toBeNull();
|
||||
expect(await answerOffline(ctx, [['Email/set', { update: { a: { mailboxIds: {} } } }, '0']])).toBeNull();
|
||||
expect(await answerOffline(ctx, [['Email/set', { update: { c: { 'mailboxIds/archive': null } } }, '0']])).toBeNull();
|
||||
expect(refused).toEqual(['destroy', 'destroy', 'destroy']);
|
||||
expect(queued).toEqual([]);
|
||||
expect((await emailIndex('acc')).size).toBe(3);
|
||||
});
|
||||
|
||||
it('refuses other changes and new messages without queueing', async () => {
|
||||
const onRefused = vi.fn();
|
||||
expect(await answerOffline({ ...ctx, onRefused }, [['Email/set', { update: { a: { subject: 'x' } } }, '0']])).toBeNull();
|
||||
expect(await answerOffline({ ...ctx, onRefused }, [['Email/set', { create: { d: { subject: 'draft' } } }, '0']])).toBeNull();
|
||||
expect(onRefused).toHaveBeenCalledTimes(1);
|
||||
expect(queued).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* The mail kept on this device for offline mode (#523), per Bulwark account
|
||||
* (`account`, the local account id) and JMAP account (`jmap`).
|
||||
*
|
||||
* Messages are kept in two parts: what the list shows (emails) and the text
|
||||
* and structure of the message (bodies), so a folder list never decrypts
|
||||
* the bodies. Attachments and inline images are blobs. Every value is
|
||||
* encrypted (crypto.ts); only the ledger (size, date) and the keys (ids) are
|
||||
* not.
|
||||
*
|
||||
* The cache holds at most CACHE_CAP_BYTES on the device, across accounts;
|
||||
* the oldest messages go first, with their bodies and attachments. The
|
||||
* outbox (queue.ts) doesn't count and never goes.
|
||||
*/
|
||||
|
||||
import { idbDeleteAccount, idbGet, idbGetAll, idbGetAllByAccount, idbWrite, type StoreName } from './db';
|
||||
import { openBytes, openJson, sealBytes, sealJson, type Sealed } from './crypto';
|
||||
|
||||
/** Most bytes the cache keeps on the device (messages, bodies, attachments). */
|
||||
export const CACHE_CAP_BYTES = 250 * 1024 * 1024;
|
||||
/** How far back the Inbox (and the folders the user opened) are kept. */
|
||||
export const CACHE_DAYS = 30;
|
||||
export const CACHE_WINDOW_MS = CACHE_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** The parts of a message that only the reader needs. */
|
||||
const BODY_KEYS = ['bodyValues', 'textBody', 'htmlBody', 'bodyStructure', 'headers', 'attachments'] as const;
|
||||
|
||||
export type CachedEmail = Record<string, unknown> & {
|
||||
id: string;
|
||||
threadId?: string;
|
||||
mailboxIds?: Record<string, boolean>;
|
||||
keywords?: Record<string, boolean>;
|
||||
receivedAt?: string;
|
||||
};
|
||||
|
||||
interface SealedRecord extends Sealed {
|
||||
key: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
interface LedgerEntry {
|
||||
key: string;
|
||||
account: string;
|
||||
store: StoreName;
|
||||
ref: string;
|
||||
/** The message's date (ms), for oldest-first eviction. */
|
||||
at: number;
|
||||
bytes: number;
|
||||
/** The message a body or blob belongs to. */
|
||||
emailKey?: string;
|
||||
}
|
||||
|
||||
const join = (...parts: string[]) => parts.join('\u0001');
|
||||
export const emailKey = (account: string, jmap: string, id: string) => join(account, jmap, id);
|
||||
const blobKey = (account: string, jmap: string, blobId: string) => join(account, jmap, 'blob', blobId);
|
||||
const miscKey = (account: string, name: string) => join(account, 'misc', name);
|
||||
const ledgerKey = (store: StoreName, key: string) => `${store}:${key}`;
|
||||
|
||||
const dateOf = (email: { receivedAt?: unknown }) => {
|
||||
const at = typeof email.receivedAt === 'string' ? Date.parse(email.receivedAt) : NaN;
|
||||
return Number.isFinite(at) ? at : 0;
|
||||
};
|
||||
|
||||
const byteSize = (sealed: Sealed) => sealed.data.byteLength + 64;
|
||||
|
||||
// Decrypted message metadata per account, loaded on first use.
|
||||
const indexes = new Map<string, Promise<Map<string, CachedEmail>>>();
|
||||
|
||||
/** The decrypted metadata of every cached message of an account, by emailKey. */
|
||||
export function emailIndex(account: string): Promise<Map<string, CachedEmail>> {
|
||||
let index = indexes.get(account);
|
||||
if (!index) {
|
||||
index = (async () => {
|
||||
const map = new Map<string, CachedEmail>();
|
||||
const records = await idbGetAllByAccount<SealedRecord>('emails', account);
|
||||
for (const record of records) {
|
||||
try {
|
||||
map.set(record.key, await openJson<CachedEmail>(record));
|
||||
} catch {
|
||||
// Sealed with a key that is gone: dropped at the next write.
|
||||
}
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
index.catch(() => indexes.delete(account));
|
||||
indexes.set(account, index);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Forget what is held in memory (after a wipe). */
|
||||
export function forgetIndexes(account?: string): void {
|
||||
if (account) indexes.delete(account);
|
||||
else indexes.clear();
|
||||
}
|
||||
|
||||
function splitBody(email: CachedEmail): { meta: CachedEmail; body: Record<string, unknown> | null } {
|
||||
const meta: CachedEmail = { ...email };
|
||||
let body: Record<string, unknown> | null = null;
|
||||
for (const key of BODY_KEYS) {
|
||||
if (key in meta) {
|
||||
body ??= {};
|
||||
body[key] = meta[key];
|
||||
delete meta[key];
|
||||
}
|
||||
}
|
||||
// Only a fetch with the text counts as the message's body.
|
||||
return { meta, body: body && 'bodyValues' in body ? body : null };
|
||||
}
|
||||
|
||||
/** Keep messages (as the server sent them, any properties) and their bodies when they came with one. */
|
||||
export async function putEmails(account: string, jmap: string, emails: CachedEmail[]): Promise<void> {
|
||||
if (emails.length === 0) return;
|
||||
const index = await emailIndex(account);
|
||||
const ops: Array<{ store: StoreName; put?: unknown; del?: IDBValidKey }> = [];
|
||||
for (const email of emails) {
|
||||
if (!email || typeof email.id !== 'string') continue;
|
||||
const key = emailKey(account, jmap, email.id);
|
||||
const { meta, body } = splitBody(email);
|
||||
const merged = { ...(index.get(key) ?? {}), ...meta } as CachedEmail;
|
||||
index.set(key, merged);
|
||||
const at = dateOf(merged);
|
||||
const sealed = await sealJson(merged);
|
||||
ops.push({ store: 'emails', put: { key, account, ...sealed } });
|
||||
ops.push({ store: 'ledger', put: { key: ledgerKey('emails', key), account, store: 'emails', ref: key, at, bytes: byteSize(sealed), emailKey: key } satisfies LedgerEntry });
|
||||
if (body) {
|
||||
const sealedBody = await sealJson(body);
|
||||
ops.push({ store: 'bodies', put: { key, account, ...sealedBody } });
|
||||
ops.push({ store: 'ledger', put: { key: ledgerKey('bodies', key), account, store: 'bodies', ref: key, at, bytes: byteSize(sealedBody), emailKey: key } satisfies LedgerEntry });
|
||||
}
|
||||
}
|
||||
await idbWrite(ops);
|
||||
}
|
||||
|
||||
/** Apply a change made to messages (keywords, folders), here or on the server. */
|
||||
export async function patchEmails(account: string, jmap: string, patches: Record<string, Record<string, unknown>>): Promise<void> {
|
||||
const index = await emailIndex(account);
|
||||
const changed: CachedEmail[] = [];
|
||||
for (const [id, patch] of Object.entries(patches)) {
|
||||
const current = index.get(emailKey(account, jmap, id));
|
||||
if (!current) continue;
|
||||
changed.push(applyPatch(current, patch));
|
||||
}
|
||||
if (changed.length) await putEmails(account, jmap, changed);
|
||||
}
|
||||
|
||||
/** A JMAP PatchObject (RFC 8620 §5.3) applied to a message, for the properties offline mode changes. */
|
||||
export function applyPatch(email: CachedEmail, patch: Record<string, unknown>): CachedEmail {
|
||||
const next: CachedEmail = { ...email, keywords: { ...(email.keywords ?? {}) }, mailboxIds: { ...(email.mailboxIds ?? {}) } };
|
||||
for (const [path, value] of Object.entries(patch)) {
|
||||
const [prop, ...rest] = path.split('/');
|
||||
if (rest.length === 0) {
|
||||
(next as Record<string, unknown>)[prop] = value;
|
||||
continue;
|
||||
}
|
||||
if (prop !== 'keywords' && prop !== 'mailboxIds') continue;
|
||||
const name = rest.join('/').replace(/~1/g, '/').replace(/~0/g, '~');
|
||||
const map = next[prop] as Record<string, boolean>;
|
||||
if (value === null || value === false) delete map[name];
|
||||
else map[name] = true;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function removeEmails(account: string, keys: string[]): Promise<void> {
|
||||
if (keys.length === 0) return;
|
||||
const index = await emailIndex(account);
|
||||
const ops: Array<{ store: StoreName; del: IDBValidKey }> = [];
|
||||
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) });
|
||||
}
|
||||
await idbWrite(ops);
|
||||
}
|
||||
|
||||
/** 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<Record<string, unknown> | null> {
|
||||
const record = await idbGet<SealedRecord>('bodies', emailKey(account, jmap, id));
|
||||
if (!record) return null;
|
||||
try {
|
||||
return await openJson<Record<string, unknown>>(record);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasBody(account: string, jmap: string, id: string): Promise<boolean> {
|
||||
return (await idbGet<SealedRecord>('bodies', emailKey(account, jmap, id))) !== undefined;
|
||||
}
|
||||
|
||||
export async function putBlob(account: string, jmap: string, blobId: string, blob: Blob, at: number, ofEmail?: string): Promise<void> {
|
||||
const key = blobKey(account, jmap, blobId);
|
||||
const sealed = await sealBytes(await blob.arrayBuffer());
|
||||
await idbWrite([
|
||||
{ store: 'blobs', put: { key, account, type: blob.type, ...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<Blob | null> {
|
||||
const record = await idbGet<SealedRecord & { type?: string }>('blobs', blobKey(account, jmap, blobId));
|
||||
if (!record) return null;
|
||||
try {
|
||||
return new Blob([await openBytes(record)], { type: record.type || 'application/octet-stream' });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasBlob(account: string, jmap: string, blobId: string): Promise<boolean> {
|
||||
return (await idbGet<SealedRecord>('blobs', blobKey(account, jmap, blobId))) !== undefined;
|
||||
}
|
||||
|
||||
/** Small named values of an account (session, folders, identities, opened folders). */
|
||||
export async function putMisc(account: string, name: string, value: unknown): Promise<void> {
|
||||
const key = miscKey(account, name);
|
||||
await idbWrite([{ store: 'misc', put: { key, account, ...(await sealJson(value)) } }]);
|
||||
}
|
||||
|
||||
export async function getMisc<T>(account: string, name: string): Promise<T | null> {
|
||||
const record = await idbGet<SealedRecord>('misc', miscKey(account, name));
|
||||
if (!record) return null;
|
||||
try {
|
||||
return await openJson<T>(record);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bytes the cache uses on this device, and per account. */
|
||||
export async function cacheUsage(): Promise<{ total: number; byAccount: Record<string, number> }> {
|
||||
const entries = await idbGetAll<LedgerEntry>('ledger');
|
||||
const byAccount: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const entry of entries) {
|
||||
total += entry.bytes;
|
||||
byAccount[entry.account] = (byAccount[entry.account] ?? 0) + entry.bytes;
|
||||
}
|
||||
return { total, byAccount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the cache under `cap`: the oldest messages go first, each with its
|
||||
* body and attachments. Returns the bytes freed.
|
||||
*/
|
||||
export async function enforceCap(cap: number = CACHE_CAP_BYTES): Promise<number> {
|
||||
const entries = await idbGetAll<LedgerEntry>('ledger');
|
||||
let total = entries.reduce((sum, entry) => sum + entry.bytes, 0);
|
||||
if (total <= cap) return 0;
|
||||
// Group by message: a message and everything that belongs to it go together.
|
||||
const groups = new Map<string, { at: number; bytes: number; account: string; entries: LedgerEntry[] }>();
|
||||
for (const entry of entries) {
|
||||
const id = entry.emailKey ?? entry.key;
|
||||
const group = groups.get(id) ?? { at: entry.at, bytes: 0, account: entry.account, entries: [] };
|
||||
group.at = Math.max(group.at, entry.at);
|
||||
group.bytes += entry.bytes;
|
||||
group.entries.push(entry);
|
||||
groups.set(id, group);
|
||||
}
|
||||
const oldestFirst = [...groups.values()].sort((a, b) => a.at - b.at);
|
||||
let freed = 0;
|
||||
const ops: Array<{ store: StoreName; del: IDBValidKey }> = [];
|
||||
for (const group of oldestFirst) {
|
||||
if (total <= cap) break;
|
||||
for (const entry of group.entries) {
|
||||
ops.push({ store: entry.store, del: entry.ref }, { store: 'ledger', del: entry.key });
|
||||
if (entry.store === 'emails') (await indexes.get(entry.account))?.delete(entry.ref);
|
||||
}
|
||||
total -= group.bytes;
|
||||
freed += group.bytes;
|
||||
}
|
||||
await idbWrite(ops);
|
||||
return freed;
|
||||
}
|
||||
|
||||
/** Everything cached for one account (not its outbox). */
|
||||
export async function wipeAccountCache(account: string): Promise<void> {
|
||||
forgetIndexes(account);
|
||||
await idbDeleteAccount(account, ['emails', 'bodies', 'blobs', 'misc', 'ledger']);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Encryption at rest for offline mode (#523): AES-GCM with a 256-bit key
|
||||
* the browser generates as non-extractable and keeps in the offline
|
||||
* database. Script on this origin can't read the key's bytes, only use it.
|
||||
*
|
||||
* What it protects against: someone who copies the browser profile's files
|
||||
* (a backup, a stolen disk image) without being able to run this site in
|
||||
* that profile. What it doesn't: someone using the unlocked browser profile,
|
||||
* or a script running on this origin, can decrypt everything. The docs say
|
||||
* so and recommend disk encryption.
|
||||
*/
|
||||
|
||||
import { idbGet, idbWrite } from './db';
|
||||
|
||||
export interface Sealed {
|
||||
iv: Uint8Array;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
let keyPromise: Promise<CryptoKey> | null = null;
|
||||
|
||||
async function loadOrCreateKey(): Promise<CryptoKey> {
|
||||
const stored = await idbGet<{ id: string; key: CryptoKey }>('keys', 'aes');
|
||||
if (stored?.key) return stored.key;
|
||||
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
||||
await idbWrite([{ store: 'keys', put: { id: 'aes', key } }]);
|
||||
return key;
|
||||
}
|
||||
|
||||
function deviceKey(): Promise<CryptoKey> {
|
||||
if (!keyPromise) {
|
||||
keyPromise = loadOrCreateKey();
|
||||
keyPromise.catch(() => { keyPromise = null; });
|
||||
}
|
||||
return keyPromise;
|
||||
}
|
||||
|
||||
/** Forget the key in memory (the database with it was deleted). */
|
||||
export function forgetDeviceKey(): void {
|
||||
keyPromise = null;
|
||||
}
|
||||
|
||||
export async function sealBytes(bytes: ArrayBuffer | Uint8Array): Promise<Sealed> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const data = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, await deviceKey(), bytes as BufferSource);
|
||||
return { iv, data };
|
||||
}
|
||||
|
||||
export async function openBytes(sealed: Sealed): Promise<ArrayBuffer> {
|
||||
return crypto.subtle.decrypt({ name: 'AES-GCM', iv: sealed.iv as BufferSource }, await deviceKey(), sealed.data);
|
||||
}
|
||||
|
||||
export async function sealJson(value: unknown): Promise<Sealed> {
|
||||
return sealBytes(new TextEncoder().encode(JSON.stringify(value)));
|
||||
}
|
||||
|
||||
export async function openJson<T>(sealed: Sealed): Promise<T> {
|
||||
return JSON.parse(new TextDecoder().decode(await openBytes(sealed))) as T;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* The IndexedDB database of offline mode (#523): one per browser profile,
|
||||
* deleted as a whole at a full sign-out (lib/sign-out-cleanup.ts) and when
|
||||
* the last account switches offline mode off.
|
||||
*
|
||||
* Stores (every value but the key and the ledger is encrypted, see crypto.ts):
|
||||
* - keys: the AES-GCM key, non-extractable.
|
||||
* - emails: message metadata (what the list shows), per account and JMAP account.
|
||||
* - bodies: the text of a message (bodyValues and the parts that say how to show it).
|
||||
* - blobs: attachments and inline images.
|
||||
* - misc: the session, folders, identities, the folders the user opened.
|
||||
* - ledger: size and date of each cached record, unencrypted, so the cache
|
||||
* can be kept under its cap without decrypting everything.
|
||||
* - queue: changes and messages waiting for the network (never evicted).
|
||||
* - uploads: files attached while offline, until their message is sent.
|
||||
*/
|
||||
|
||||
export const OFFLINE_DB_NAME = 'bulwark-offline';
|
||||
const VERSION = 1;
|
||||
|
||||
export const STORES = ['keys', 'emails', 'bodies', 'blobs', 'misc', 'ledger', 'queue', 'uploads'] as const;
|
||||
export type StoreName = (typeof STORES)[number];
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
export function hasIndexedDb(): boolean {
|
||||
try {
|
||||
return typeof indexedDB !== 'undefined' && indexedDB !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function openOfflineDb(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise;
|
||||
const opening = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(OFFLINE_DB_NAME, VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
for (const name of STORES) {
|
||||
if (db.objectStoreNames.contains(name)) continue;
|
||||
if (name === 'queue') {
|
||||
db.createObjectStore(name, { keyPath: 'id', autoIncrement: true }).createIndex('account', 'account');
|
||||
} else if (name === 'keys') {
|
||||
db.createObjectStore(name, { keyPath: 'id' });
|
||||
} else {
|
||||
db.createObjectStore(name, { keyPath: 'key' }).createIndex('account', 'account');
|
||||
}
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
// A sign-out in this or another tab deletes the database: let it.
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
if (dbPromise === opening) dbPromise = null;
|
||||
};
|
||||
db.onclose = () => {
|
||||
if (dbPromise === opening) 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<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error ?? new Error('transaction aborted'));
|
||||
});
|
||||
}
|
||||
|
||||
function result<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function idbGet<T>(store: StoreName, key: IDBValidKey): Promise<T | undefined> {
|
||||
const db = await openOfflineDb();
|
||||
return result(db.transaction(store, 'readonly').objectStore(store).get(key)) as Promise<T | undefined>;
|
||||
}
|
||||
|
||||
export async function idbGetAllByAccount<T>(store: StoreName, account: string): Promise<T[]> {
|
||||
const db = await openOfflineDb();
|
||||
return result(db.transaction(store, 'readonly').objectStore(store).index('account').getAll(account)) as Promise<T[]>;
|
||||
}
|
||||
|
||||
export async function idbGetAll<T>(store: StoreName): Promise<T[]> {
|
||||
const db = await openOfflineDb();
|
||||
return result(db.transaction(store, 'readonly').objectStore(store).getAll()) as Promise<T[]>;
|
||||
}
|
||||
|
||||
/** Writes and deletes in several stores at once. */
|
||||
export async function idbWrite(ops: Array<{ store: StoreName; put?: unknown; del?: IDBValidKey }>): Promise<void> {
|
||||
if (ops.length === 0) return;
|
||||
const db = await openOfflineDb();
|
||||
const names = [...new Set(ops.map((op) => op.store))];
|
||||
const tx = db.transaction(names, 'readwrite');
|
||||
for (const op of ops) {
|
||||
const store = tx.objectStore(op.store);
|
||||
if (op.put !== undefined) store.put(op.put);
|
||||
else if (op.del !== undefined) store.delete(op.del);
|
||||
}
|
||||
await done(tx);
|
||||
}
|
||||
|
||||
export async function idbAdd(store: StoreName, value: unknown): Promise<IDBValidKey> {
|
||||
const db = await openOfflineDb();
|
||||
const tx = db.transaction(store, 'readwrite');
|
||||
const key = result(tx.objectStore(store).add(value));
|
||||
await done(tx);
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Deletes every record of one account in the given stores. */
|
||||
export async function idbDeleteAccount(account: string, stores: readonly StoreName[]): Promise<void> {
|
||||
const db = await openOfflineDb();
|
||||
const tx = db.transaction([...stores], 'readwrite');
|
||||
for (const name of stores) {
|
||||
const index = tx.objectStore(name).index('account');
|
||||
const request = index.openKeyCursor(IDBKeyRange.only(account));
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) return;
|
||||
tx.objectStore(name).delete(cursor.primaryKey);
|
||||
cursor.continue();
|
||||
};
|
||||
}
|
||||
await done(tx);
|
||||
}
|
||||
|
||||
/** Deletes the whole database (sign-out, offline mode off everywhere). */
|
||||
export async function deleteOfflineDb(): Promise<void> {
|
||||
if (!hasIndexedDb()) return;
|
||||
const open = dbPromise;
|
||||
dbPromise = null;
|
||||
try {
|
||||
(await open)?.close();
|
||||
} catch {
|
||||
/* it never opened */
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
try {
|
||||
const request = indexedDB.deleteDatabase(OFFLINE_DB_NAME);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => resolve();
|
||||
request.onblocked = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Sends what waited on this device (#523), once the server answers again:
|
||||
* first the changes (read, flagged, tags, moves) in the order they were
|
||||
* made, then the outbox, oldest first.
|
||||
*
|
||||
* - 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.
|
||||
*/
|
||||
|
||||
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 type { OfflineNotice } from './offline-mode';
|
||||
|
||||
interface FlushDeps {
|
||||
notify: (notice: OfflineNotice) => void;
|
||||
allowed: () => boolean;
|
||||
}
|
||||
|
||||
const flushing = new Set<string>();
|
||||
|
||||
/** A hold shorter than this is dropped: the undo window passed while the message waited. */
|
||||
const MIN_HOLD_MS = 10_000;
|
||||
|
||||
function stillOffline(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||
}
|
||||
|
||||
async function uploadHeld(account: string, client: JMAPClient, blobId: string): Promise<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
async function sendEntry(account: string, client: JMAPClient, entry: OutboxEntry): Promise<SendEmailResult> {
|
||||
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.');
|
||||
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();
|
||||
const sent = mailboxes.find((m) => m.role === 'sent');
|
||||
if (!sent) throw new Error('No sent mailbox found');
|
||||
const drafts = mailboxes.find((m) => m.role === 'drafts');
|
||||
return client.sendRawEmail(file, item.identityId, sent.id, drafts?.id, delayed, item.envelopeRecipients);
|
||||
}
|
||||
const args = [...item.args];
|
||||
const attachments = args[10];
|
||||
if (Array.isArray(attachments)) {
|
||||
const resolved = [];
|
||||
for (const attachment of attachments as Array<{ blobId: string }>) {
|
||||
resolved.push(isLocalBlobId(attachment.blobId) ? { ...attachment, blobId: await uploadHeld(account, client, attachment.blobId) } : attachment);
|
||||
}
|
||||
args[10] = resolved;
|
||||
}
|
||||
const delayedUntil = args[13] as string | undefined;
|
||||
if (delayedUntil && Date.parse(delayedUntil) - Date.now() <= MIN_HOLD_MS) args[13] = undefined;
|
||||
const send = client.sendEmail.bind(client) as (...a: unknown[]) => Promise<SendEmailResult>;
|
||||
return send(...args);
|
||||
}
|
||||
|
||||
export async function flushQueue(account: string, client: JMAPClient, deps: FlushDeps): Promise<void> {
|
||||
if (flushing.has(account) || !deps.allowed()) return;
|
||||
flushing.add(account);
|
||||
try {
|
||||
const { patches, outbox } = await readQueue(account);
|
||||
|
||||
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;
|
||||
}
|
||||
await removeQueued([patch.id]);
|
||||
}
|
||||
|
||||
for (const entry of outbox) {
|
||||
if (entry.status !== 'waiting') continue;
|
||||
if (stillOffline() || !deps.allowed()) return;
|
||||
await updateOutboxEntry(entry, { status: 'sending' });
|
||||
try {
|
||||
await sendEntry(account, client, entry);
|
||||
await removeOutboxEntry(entry);
|
||||
void client.pruneEmailSubmissions();
|
||||
deps.notify({ kind: 'sent', subject: entry.summary.subject });
|
||||
} 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' });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flushing.delete(account);
|
||||
}
|
||||
}
|
||||
|
||||
/** "Try again" for a failed or uncertain message. */
|
||||
export async function retryOutboxEntry(id: number): Promise<void> {
|
||||
const { outbox } = await readQueue();
|
||||
const entry = outbox.find((e) => e.id === id);
|
||||
if (entry) await updateOutboxEntry(entry, { status: 'waiting', error: undefined });
|
||||
}
|
||||
|
||||
/** "Delete" in the outbox. */
|
||||
export async function deleteOutboxEntry(id: number): Promise<void> {
|
||||
const { outbox } = await readQueue();
|
||||
const entry = outbox.find((e) => e.id === id);
|
||||
if (entry) await removeOutboxEntry(entry);
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
/**
|
||||
* Offline mode (#523): "Keep mail on this device".
|
||||
*
|
||||
* - Opt-in per account and per browser (state.ts), off by default, hidden
|
||||
* and wiped when the organisation forbids it (policy.json
|
||||
* `organisation.devices.noOffline`, per account) and in Lite.
|
||||
* - While on, the JMAP client hands every answer of the mail server to the
|
||||
* cache (cache.ts) and asks this device when the network is gone
|
||||
* (responder.ts); a background sync keeps the last 30 days of the Inbox
|
||||
* 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
|
||||
* (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).
|
||||
*/
|
||||
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { devicesForAccount } from '@/lib/device-policy';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useAuthStore } from '@/stores/auth-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 { setOutboxHooks, type OutboxHooks } from './outbox-hooks';
|
||||
import { answerOffline } from './responder';
|
||||
import {
|
||||
CACHE_WINDOW_MS,
|
||||
cacheUsage,
|
||||
emailIndex,
|
||||
forgetIndexes,
|
||||
getBlob,
|
||||
getMisc,
|
||||
patchEmails,
|
||||
putBlob,
|
||||
putEmails,
|
||||
putMisc,
|
||||
removeEmails,
|
||||
wipeAccountCache,
|
||||
emailKey,
|
||||
type CachedEmail,
|
||||
} from './cache';
|
||||
import { 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 { syncAccount } from './sync';
|
||||
import { deleteShellCaches, tellServiceWorker } from './shell';
|
||||
|
||||
export type OfflineNotice =
|
||||
| { kind: 'queued'; subject: string }
|
||||
| { kind: 'sent'; subject: string }
|
||||
| { kind: 'send-failed'; subject: string; error: string }
|
||||
| { kind: 'refused-delete' }
|
||||
| { kind: 'refused-other' }
|
||||
| { kind: 'forbidden' }
|
||||
| { kind: 'back-online-reload' };
|
||||
|
||||
let notify: (notice: OfflineNotice) => void = () => {};
|
||||
|
||||
/** Where the notices go (the host component turns them into toasts). */
|
||||
export function setOfflineNotifier(fn: (notice: OfflineNotice) => void): void {
|
||||
notify = fn;
|
||||
}
|
||||
|
||||
export function offlineNotify(notice: OfflineNotice): void {
|
||||
notify(notice);
|
||||
}
|
||||
|
||||
// ---- Who may use it ----
|
||||
|
||||
/** Offline mode is possible here at all: not in Lite, with IndexedDB and WebCrypto. */
|
||||
export function offlineSupported(): boolean {
|
||||
if (IS_LITE || !hasIndexedDb()) return false;
|
||||
try {
|
||||
return typeof crypto !== 'undefined' && !!crypto.subtle;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The organisation allows this account to keep mail on the device. */
|
||||
export function offlineAllowed(account: string | null | undefined): boolean {
|
||||
return offlineSupported() && !devicesForAccount(account ?? null).noOffline;
|
||||
}
|
||||
|
||||
/** "Keep mail on this device" is on for this account here, and allowed. */
|
||||
export function offlineActive(account: string | null | undefined): boolean {
|
||||
return !!account && useOfflineStore.getState().enabled.includes(account) && offlineAllowed(account);
|
||||
}
|
||||
|
||||
/** The local account a JMAP client belongs to. */
|
||||
export function accountForClient(client: OfflineClientRef): string | null {
|
||||
const auth = useAuthStore.getState();
|
||||
if (client === (auth.client as unknown) && auth.activeAccountId) return auth.activeAccountId;
|
||||
try {
|
||||
for (const [id, c] of auth.getAllConnectedClients()) if ((c as unknown) === client) return id;
|
||||
} catch {
|
||||
// A stand-in auth store in tests.
|
||||
}
|
||||
const server = client.getServerUrl().replace(/\/+$/, '');
|
||||
const user = client.getUsername();
|
||||
const entry = useAccountStore.getState().accounts.find((a) => a.serverUrl.replace(/\/+$/, '') === server && a.username === user);
|
||||
return entry?.id ?? null;
|
||||
}
|
||||
|
||||
function onlineClientFor(account: string) {
|
||||
const client = useAuthStore.getState().getClientForAccount(account);
|
||||
if (!client || client.isOfflineOnly()) return null;
|
||||
return client;
|
||||
}
|
||||
|
||||
// ---- What the server sends, into the cache ----
|
||||
|
||||
interface Scope {
|
||||
inbox: Record<string, string>;
|
||||
opened: Record<string, string[]>;
|
||||
}
|
||||
|
||||
const MAX_OPENED_FOLDERS = 10;
|
||||
|
||||
async function scopeOf(account: string): Promise<Scope> {
|
||||
const mailboxes = (await getMisc<Record<string, Array<{ id: string; role?: string | null }>>>(account, 'mailboxes')) ?? {};
|
||||
const inbox: Record<string, string> = {};
|
||||
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<Record<string, string[]>>(account, 'opened')) ?? {};
|
||||
return { inbox, opened };
|
||||
}
|
||||
|
||||
export async function scopeMailboxes(account: string): Promise<Array<{ jmap: string; mailboxId: string; isInbox: boolean }>> {
|
||||
const scope = await scopeOf(account);
|
||||
const out: Array<{ jmap: string; mailboxId: string; isInbox: boolean }> = [];
|
||||
for (const [jmap, id] of Object.entries(scope.inbox)) out.push({ jmap, mailboxId: id, isInbox: true });
|
||||
for (const [jmap, ids] of Object.entries(scope.opened)) {
|
||||
for (const id of ids) if (scope.inbox[jmap] !== id) out.push({ jmap, mailboxId: id, isInbox: false });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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 ?? {});
|
||||
const wanted = new Set([...(scope.opened[jmap] ?? []), ...(scope.inbox[jmap] ? [scope.inbox[jmap]] : [])]);
|
||||
return boxes.some((id) => wanted.has(id));
|
||||
}
|
||||
|
||||
/** The folder a list query opened, if it is one. */
|
||||
function openedFolder(args: Record<string, unknown>): string | null {
|
||||
if (typeof args.limit !== 'number') return null;
|
||||
const filter = args.filter as Record<string, unknown> | undefined;
|
||||
if (!filter) return null;
|
||||
if (typeof filter.inMailbox === 'string' && Object.keys(filter).every((k) => k === 'inMailbox' || k === 'hasKeyword')) return filter.inMailbox;
|
||||
if (filter.operator === 'AND' && Array.isArray(filter.conditions)) {
|
||||
for (const c of filter.conditions as Array<Record<string, unknown>>) if (typeof c.inMailbox === 'string' && Object.keys(c).length === 1) return c.inMailbox;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function noteOpenedFolder(account: string, jmap: string, mailboxId: string): Promise<void> {
|
||||
const opened = (await getMisc<Record<string, string[]>>(account, 'opened')) ?? {};
|
||||
const list = opened[jmap] ?? [];
|
||||
if (list[0] === mailboxId) return;
|
||||
opened[jmap] = [mailboxId, ...list.filter((id) => id !== mailboxId)].slice(0, MAX_OPENED_FOLDERS);
|
||||
await putMisc(account, 'opened', opened);
|
||||
scheduleSync(account, 5_000);
|
||||
}
|
||||
|
||||
async function putList(account: string, name: 'mailboxes' | 'identities', jmap: string, list: unknown[]): Promise<void> {
|
||||
const all = (await getMisc<Record<string, unknown[]>>(account, name)) ?? {};
|
||||
all[jmap] = list;
|
||||
await putMisc(account, name, all);
|
||||
}
|
||||
|
||||
async function recordAnswer(account: string, defaultJmap: string, calls: OfflineMethodCall[], response: OfflineResponse): Promise<void> {
|
||||
const scope = await scopeOf(account);
|
||||
for (const [name, result, callId] of response.methodResponses) {
|
||||
if (!result || typeof result !== 'object') continue;
|
||||
const call = calls.find(([n, , id]) => id === callId && n === name);
|
||||
const args = call?.[1] ?? {};
|
||||
const jmap = (result.accountId as string) || (args.accountId as string) || defaultJmap;
|
||||
const properties = args.properties as string[] | undefined;
|
||||
switch (name) {
|
||||
case 'Mailbox/get':
|
||||
if (!args.ids && (!properties || properties.includes('name')) && Array.isArray(result.list)) {
|
||||
await putList(account, 'mailboxes', jmap, result.list);
|
||||
}
|
||||
break;
|
||||
case 'Identity/get':
|
||||
if (!args.ids && Array.isArray(result.list)) await putList(account, 'identities', jmap, result.list);
|
||||
break;
|
||||
case 'Email/get': {
|
||||
const list = (Array.isArray(result.list) ? result.list : []) as CachedEmail[];
|
||||
// Fresh state for every message already kept; new ones only within the scope.
|
||||
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);
|
||||
break;
|
||||
}
|
||||
case 'Email/query': {
|
||||
const folder = openedFolder(args);
|
||||
if (folder) await noteOpenedFolder(account, jmap, folder);
|
||||
break;
|
||||
}
|
||||
case 'Email/set': {
|
||||
const update = (args.update ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const updated = Object.keys((result.updated ?? {}) as object);
|
||||
if (updated.length) await patchEmails(account, jmap, Object.fromEntries(updated.filter((id) => update[id]).map((id) => [id, update[id]])));
|
||||
const destroyed = (Array.isArray(result.destroyed) ? result.destroyed : []) as string[];
|
||||
if (destroyed.length) await removeEmails(account, destroyed.map((id) => emailKey(account, jmap, id)));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One write after the other per account, so a list and its refresh land in order.
|
||||
const recording = new Map<string, Promise<void>>();
|
||||
|
||||
function enqueueRecord(account: string, task: () => Promise<void>): void {
|
||||
const previous = recording.get(account) ?? Promise.resolve();
|
||||
const next = previous.then(task).catch(() => {});
|
||||
recording.set(account, next);
|
||||
}
|
||||
|
||||
// ---- Hooks for the JMAP client ----
|
||||
|
||||
let networkWasDown = false;
|
||||
|
||||
const hooks: OfflineHooks = {
|
||||
skipNetwork(client) {
|
||||
if (typeof navigator === 'undefined' || navigator.onLine !== false) return false;
|
||||
return offlineActive(accountForClient(client));
|
||||
},
|
||||
async answer(client, methodCalls) {
|
||||
const account = accountForClient(client);
|
||||
if (!offlineActive(account) || !account) return null;
|
||||
return answerOffline({
|
||||
account,
|
||||
defaultJmap: client.getAccountId(),
|
||||
queuePatch: async (jmap, update) => {
|
||||
await queuePatch(account, jmap, update);
|
||||
void refreshQueueState();
|
||||
},
|
||||
onRefused: (what) => notify({ kind: what === 'destroy' ? 'refused-delete' : 'refused-other' }),
|
||||
}, methodCalls);
|
||||
},
|
||||
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[];
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
networkWasDown = true;
|
||||
if (!useOfflineStore.getState().networkDown) useOfflineStore.setState({ networkDown: true });
|
||||
}
|
||||
},
|
||||
async blob(client, blobId, jmap) {
|
||||
const account = accountForClient(client);
|
||||
if (!account || !offlineActive(account)) return null;
|
||||
if (isLocalBlobId(blobId)) return heldFile(account, blobId);
|
||||
return getBlob(account, jmap, blobId);
|
||||
},
|
||||
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()));
|
||||
},
|
||||
async holdUpload(client, file) {
|
||||
const account = accountForClient(client);
|
||||
if (!account || !offlineActive(account)) return null;
|
||||
return holdFile(account, file, file.name);
|
||||
},
|
||||
};
|
||||
|
||||
/** Attachments and images opened while online are kept up to this size. */
|
||||
const MAX_RECORDED_BLOB_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
// ---- The outbox ----
|
||||
|
||||
function summaryOf(args: unknown[]): OutboxSummary {
|
||||
const [to, subject, , cc, bcc] = args as [string[] | undefined, string | undefined, unknown, string[] | undefined, string[] | undefined];
|
||||
return { subject: subject ?? '', to: [...(to ?? []), ...(cc ?? []), ...(bcc ?? [])] };
|
||||
}
|
||||
|
||||
function queuedResult(): { scheduled: false; queued: true } {
|
||||
return { scheduled: false, queued: true };
|
||||
}
|
||||
|
||||
const outbox: OutboxHooks = {
|
||||
shouldQueue(client) {
|
||||
const account = accountForClient(client);
|
||||
return !!account && offlineActive(account) && (isOfflineNow() || (typeof navigator !== 'undefined' && navigator.onLine === false));
|
||||
},
|
||||
async queueEmail(client, args) {
|
||||
const account = accountForClient(client);
|
||||
if (!account) throw new Error('No account for this send');
|
||||
const options = { ...((args[15] as Record<string, unknown> | undefined) ?? {}) };
|
||||
delete options.outbox;
|
||||
const stored = [...args.slice(0, 15), options];
|
||||
const summary = summaryOf(stored);
|
||||
await queueSend(account, summary, { kind: 'email', args: stored });
|
||||
await refreshQueueState();
|
||||
notify({ kind: 'queued', subject: summary.subject });
|
||||
return queuedResult();
|
||||
},
|
||||
async queueRaw(client, raw, options) {
|
||||
const account = accountForClient(client);
|
||||
if (!account) throw new Error('No account for this send');
|
||||
const held = await holdFile(account, raw, 'message.eml');
|
||||
const summary = await rawSummary(raw);
|
||||
await queueSend(account, summary, { kind: 'raw', blobId: held.blobId, ...options });
|
||||
await refreshQueueState();
|
||||
notify({ kind: 'queued', subject: summary.subject });
|
||||
return queuedResult();
|
||||
},
|
||||
async resolveHeldAttachments(client, attachments) {
|
||||
const account = accountForClient(client);
|
||||
if (!account || !attachments.some((a) => isLocalBlobId(a.blobId))) return attachments;
|
||||
const real = onlineClientFor(account);
|
||||
if (!real) return attachments;
|
||||
const out = [];
|
||||
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.');
|
||||
const { blobId } = await real.uploadBlob(file);
|
||||
out.push({ ...attachment, blobId });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
||||
/** Subject and recipients of a raw message, for the outbox list. */
|
||||
async function rawSummary(raw: Blob): Promise<OutboxSummary> {
|
||||
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 };
|
||||
}
|
||||
|
||||
function decodeMimeWords(value: string): string {
|
||||
return value.replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (whole, charset: string, encoding: string, text: string) => {
|
||||
try {
|
||||
const bytes = encoding.toUpperCase() === 'B'
|
||||
? Uint8Array.from(atob(text), (c) => c.charCodeAt(0))
|
||||
: Uint8Array.from(text.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, h: string) => String.fromCharCode(parseInt(h, 16))), (c) => c.charCodeAt(0));
|
||||
return new TextDecoder(charset).decode(bytes);
|
||||
} catch {
|
||||
return whole;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Reads the queue into the store, for the outbox view and the banner. */
|
||||
export async function refreshQueueState(): Promise<void> {
|
||||
if (!offlineSupported()) return;
|
||||
try {
|
||||
const { patches, outbox: entries } = await readQueue();
|
||||
const rows: OutboxRow[] = entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
account: entry.account,
|
||||
createdAt: entry.createdAt,
|
||||
subject: entry.summary.subject,
|
||||
to: entry.summary.to,
|
||||
status: entry.status,
|
||||
error: entry.error,
|
||||
}));
|
||||
useOfflineStore.setState({ outbox: rows, pendingChanges: patches.length });
|
||||
} catch {
|
||||
// The database is being deleted.
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshUsage(): Promise<void> {
|
||||
if (!offlineSupported()) return;
|
||||
try {
|
||||
useOfflineStore.setState({ usageBytes: (await cacheUsage()).total });
|
||||
} catch {
|
||||
/* deleted meanwhile */
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Sync and flush timing ----
|
||||
|
||||
const syncTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
export function scheduleSync(account: string, delayMs = 2_000): void {
|
||||
if (!offlineActive(account)) return;
|
||||
const existing = syncTimers.get(account);
|
||||
if (existing) clearTimeout(existing);
|
||||
syncTimers.set(account, setTimeout(() => {
|
||||
syncTimers.delete(account);
|
||||
void runSync(account);
|
||||
}, delayMs));
|
||||
}
|
||||
|
||||
const syncsRunning = new Set<string>();
|
||||
|
||||
export async function runSync(account: string): Promise<void> {
|
||||
if (syncsRunning.has(account) || !offlineActive(account)) return;
|
||||
const client = onlineClientFor(account);
|
||||
if (!client || isOfflineNow()) return;
|
||||
syncsRunning.add(account);
|
||||
useOfflineStore.setState((s) => ({ syncing: [...new Set([...s.syncing, account])] }));
|
||||
try {
|
||||
await syncAccount(account, client, { stillActive: () => offlineActive(account), scopeMailboxes });
|
||||
useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [account]: Date.now() } }));
|
||||
} catch {
|
||||
// Tried again at the next change or in 15 minutes.
|
||||
} finally {
|
||||
syncsRunning.delete(account);
|
||||
useOfflineStore.setState((s) => ({ syncing: s.syncing.filter((id) => id !== account) }));
|
||||
void refreshUsage();
|
||||
}
|
||||
}
|
||||
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function scheduleFlushAll(delayMs = 1_000): void {
|
||||
if (flushTimer) clearTimeout(flushTimer);
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
void flushAll();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
export async function flushAll(): Promise<void> {
|
||||
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' });
|
||||
return;
|
||||
}
|
||||
const { outbox: rows, pendingChanges } = useOfflineStore.getState();
|
||||
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;
|
||||
await flushQueue(account, client, {
|
||||
notify,
|
||||
allowed: () => offlineAllowed(account),
|
||||
});
|
||||
}
|
||||
await refreshQueueState();
|
||||
for (const account of useOfflineStore.getState().enabled) scheduleSync(account, 3_000);
|
||||
}
|
||||
|
||||
// ---- Switching it on and off, wiping ----
|
||||
|
||||
export async function enableOffline(account: string): Promise<void> {
|
||||
if (!offlineAllowed(account)) return;
|
||||
const enabled = useOfflineStore.getState().enabled;
|
||||
if (!enabled.includes(account)) writeEnabled([...enabled, account]);
|
||||
const client = onlineClientFor(account);
|
||||
if (client) await putMisc(account, 'session', client.getSessionSnapshot());
|
||||
await tellServiceWorker(true);
|
||||
scheduleSync(account, 0);
|
||||
}
|
||||
|
||||
/** Switches offline mode off for an account and removes everything it kept here. */
|
||||
export async function disableOffline(account: string): Promise<void> {
|
||||
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
|
||||
await wipeAccount(account);
|
||||
}
|
||||
|
||||
export async function wipeAccount(account: string): Promise<void> {
|
||||
const timer = syncTimers.get(account);
|
||||
if (timer) clearTimeout(timer);
|
||||
syncTimers.delete(account);
|
||||
if (useOfflineStore.getState().enabled.includes(account)) {
|
||||
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
|
||||
}
|
||||
try {
|
||||
await recording.get(account);
|
||||
await wipeAccountCache(account);
|
||||
await wipeAccountQueue(account);
|
||||
} catch {
|
||||
// No database: nothing to remove.
|
||||
}
|
||||
if (useOfflineStore.getState().enabled.length === 0) {
|
||||
await wipeEverything();
|
||||
return;
|
||||
}
|
||||
await refreshQueueState();
|
||||
await refreshUsage();
|
||||
}
|
||||
|
||||
/** Nothing of offline mode stays on this device. */
|
||||
export async function wipeEverything(): Promise<void> {
|
||||
for (const timer of syncTimers.values()) clearTimeout(timer);
|
||||
syncTimers.clear();
|
||||
writeEnabled([]);
|
||||
forgetIndexes();
|
||||
forgetDeviceKey();
|
||||
await deleteOfflineDb();
|
||||
await tellServiceWorker(false);
|
||||
await deleteShellCaches();
|
||||
useOfflineStore.setState({ outbox: [], pendingChanges: 0, usageBytes: 0, syncing: [], lastSyncedAt: {} });
|
||||
}
|
||||
|
||||
/** 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 (!offlineAllowed(account)) {
|
||||
void wipeAccount(account);
|
||||
notify({ kind: 'forbidden' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Starting ----
|
||||
|
||||
let started = false;
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
/** Runs offline mode in this tab. Safe to call more than once. */
|
||||
export function startOfflineMode(): () => void {
|
||||
if (started || !offlineSupported()) return () => {};
|
||||
started = true;
|
||||
setOfflineHooks(hooks);
|
||||
setOutboxHooks(outbox);
|
||||
|
||||
const onOnline = () => {
|
||||
useOfflineStore.setState({ browserOffline: false });
|
||||
scheduleFlushAll(500);
|
||||
};
|
||||
const onOffline = () => useOfflineStore.setState({ browserOffline: true });
|
||||
window.addEventListener('online', onOnline);
|
||||
window.addEventListener('offline', onOffline);
|
||||
cleanups.push(() => {
|
||||
window.removeEventListener('online', onOnline);
|
||||
window.removeEventListener('offline', onOffline);
|
||||
});
|
||||
|
||||
// The organisation's word, on every policy fetch (and the cached copy at an offline start).
|
||||
cleanups.push(usePolicyStore.subscribe((state, prev) => {
|
||||
if (state.rawPolicy !== prev.rawPolicy || state.loaded !== prev.loaded || state.subject !== prev.subject) enforcePolicy();
|
||||
}));
|
||||
enforcePolicy();
|
||||
|
||||
// An account that signs out (or is removed) takes its offline data with it.
|
||||
let known = new Set(useAccountStore.getState().accounts.map((a) => a.id));
|
||||
cleanups.push(useAccountStore.subscribe((state) => {
|
||||
const now = new Set(state.accounts.map((a) => a.id));
|
||||
for (const id of known) if (!now.has(id)) void wipeAccount(id);
|
||||
known = now;
|
||||
}));
|
||||
|
||||
// Session, then a sync, for every account signed in with offline mode on.
|
||||
const syncSignedIn = () => {
|
||||
for (const account of useOfflineStore.getState().enabled) {
|
||||
const client = onlineClientFor(account);
|
||||
if (!client || !offlineActive(account)) continue;
|
||||
void putMisc(account, 'session', client.getSessionSnapshot()).catch(() => {});
|
||||
scheduleSync(account, 3_000);
|
||||
}
|
||||
};
|
||||
cleanups.push(useAuthStore.subscribe((state, prev) => {
|
||||
if (state.client !== prev.client || state.connectedAccountsRevision !== prev.connectedAccountsRevision) syncSignedIn();
|
||||
}));
|
||||
syncSignedIn();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
for (const account of useOfflineStore.getState().enabled) scheduleSync(account, 0);
|
||||
}, SYNC_INTERVAL_MS);
|
||||
cleanups.push(() => clearInterval(interval));
|
||||
|
||||
if (useOfflineStore.getState().enabled.length > 0) void tellServiceWorker(true);
|
||||
void refreshQueueState().then(() => {
|
||||
if (!isOfflineNow()) scheduleFlushAll(2_000);
|
||||
});
|
||||
void refreshUsage();
|
||||
|
||||
return () => {
|
||||
for (const cleanup of cleanups.splice(0)) cleanup();
|
||||
setOfflineHooks(null);
|
||||
setOutboxHooks(null);
|
||||
started = false;
|
||||
};
|
||||
}
|
||||
|
||||
/** How often the cache catches up while the tab is open. */
|
||||
export const SYNC_INTERVAL_MS = 15 * 60 * 1000;
|
||||
|
||||
// A full sign-out deletes the database (lib/sign-out-cleanup.ts); forget
|
||||
// what is held in memory as well.
|
||||
onFullSignOut(() => {
|
||||
for (const timer of syncTimers.values()) clearTimeout(timer);
|
||||
syncTimers.clear();
|
||||
forgetIndexes();
|
||||
forgetDeviceKey();
|
||||
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).
|
||||
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));
|
||||
});
|
||||
|
||||
/** The session kept for an account, for starting without the server. */
|
||||
export async function offlineSessionFor(account: string): Promise<unknown | null> {
|
||||
// The organisation's last word: the policy kept from the last fetch (the
|
||||
// fetch itself fails without a network), never the defaults.
|
||||
const policy = usePolicyStore.getState();
|
||||
if (!policy.loaded && !policy.fetching) await policy.fetchPolicy();
|
||||
else await whenPolicySettled(3000);
|
||||
if (!offlineActive(account)) return null;
|
||||
try {
|
||||
return await getMisc<unknown>(account, 'session');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function markStartedOffline(): void {
|
||||
useOfflineStore.setState({ startedOffline: true });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* How a send reaches the outbox (#523) without the email store importing
|
||||
* offline mode (which imports the stores). Registered by lib/offline/offline-mode.ts;
|
||||
* null while offline mode isn't running, and then every send goes out as before.
|
||||
*/
|
||||
|
||||
import type { SendEmailResult } from '@/lib/jmap/types';
|
||||
import type { OfflineClientRef } from '@/lib/jmap/offline-hooks';
|
||||
|
||||
export interface RawSendOptions {
|
||||
identityId: string;
|
||||
delayedUntil?: string;
|
||||
envelopeRecipients?: string[];
|
||||
submitOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface OutboxHooks {
|
||||
/** Whether a send from this client goes to the outbox now (offline, with offline mode on for its account). */
|
||||
shouldQueue(client: OfflineClientRef): boolean;
|
||||
/** Keeps a finished send (useEmailStore.sendEmail's arguments after the client). */
|
||||
queueEmail(client: OfflineClientRef, args: unknown[]): Promise<SendEmailResult>;
|
||||
/** Keeps a finished raw message (a plugin's signed or encrypted send). */
|
||||
queueRaw(client: OfflineClientRef, raw: Blob, options: RawSendOptions): Promise<SendEmailResult>;
|
||||
/** Uploads the files of a message that were attached offline, now that the server is back. */
|
||||
resolveHeldAttachments<T extends { blobId: string }>(client: OfflineClientRef, attachments: T[]): Promise<T[]>;
|
||||
}
|
||||
|
||||
const KEY = '__bulwarkOutboxHooks';
|
||||
type HookGlobal = typeof globalThis & { [KEY]?: OutboxHooks | null };
|
||||
|
||||
export function setOutboxHooks(hooks: OutboxHooks | null): void {
|
||||
(globalThis as HookGlobal)[KEY] = hooks;
|
||||
}
|
||||
|
||||
export function getOutboxHooks(): OutboxHooks | null {
|
||||
return (globalThis as HookGlobal)[KEY] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* What waits on this device for the network (#523): changes made offline
|
||||
* (read, flagged, tags, moves) and the outbox. Both are kept encrypted in
|
||||
* the offline database and sent in the order they were made.
|
||||
*
|
||||
* An outbox entry is a finished message: everything a send does up to the
|
||||
* transport ran when the user clicked Send (the organisation's checks,
|
||||
* the classification label, signatures and disclaimers, plugin send checks,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { idbAdd, idbDeleteAccount, idbGet, idbGetAll, idbGetAllByAccount, idbWrite } from './db';
|
||||
import { openBytes, openJson, sealBytes, sealJson, type Sealed } from './crypto';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
export interface PatchItem {
|
||||
kind: 'patch';
|
||||
jmap: string;
|
||||
update: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** The arguments of useEmailStore.sendEmail after the client (positional, as the client takes them). */
|
||||
export interface EmailSendItem {
|
||||
kind: 'email';
|
||||
args: unknown[];
|
||||
}
|
||||
|
||||
export interface RawSendItem {
|
||||
kind: 'raw';
|
||||
/** The finished message, kept as an upload until it is sent. */
|
||||
blobId: string;
|
||||
identityId: string;
|
||||
delayedUntil?: string;
|
||||
envelopeRecipients?: string[];
|
||||
/** submitRaw: no copy in Sent. */
|
||||
submitOnly?: boolean;
|
||||
}
|
||||
|
||||
export type SendItem = EmailSendItem | RawSendItem;
|
||||
|
||||
export interface OutboxSummary {
|
||||
subject: string;
|
||||
to: string[];
|
||||
}
|
||||
|
||||
export type OutboxStatus = 'waiting' | 'sending' | 'failed' | 'uncertain';
|
||||
|
||||
export interface OutboxEntry {
|
||||
id: number;
|
||||
account: string;
|
||||
createdAt: number;
|
||||
summary: OutboxSummary;
|
||||
status: OutboxStatus;
|
||||
error?: string;
|
||||
item: SendItem;
|
||||
}
|
||||
|
||||
interface QueueRecord extends Sealed {
|
||||
id?: number;
|
||||
account: string;
|
||||
kind: 'patch' | 'send';
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface SendPayload {
|
||||
summary: OutboxSummary;
|
||||
status: OutboxStatus;
|
||||
error?: string;
|
||||
item: SendItem;
|
||||
}
|
||||
|
||||
export async function queuePatch(account: string, jmap: string, update: PatchItem['update']): Promise<void> {
|
||||
const item: PatchItem = { kind: 'patch', jmap, update };
|
||||
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<number> {
|
||||
const payload: SendPayload = { summary, status: 'waiting', item };
|
||||
return Number(await idbAdd('queue', { account, kind: 'send', createdAt: Date.now(), ...(await sealJson(payload)) } satisfies QueueRecord));
|
||||
}
|
||||
|
||||
export interface QueuedPatch { id: number; account: string; item: PatchItem }
|
||||
|
||||
/** Everything waiting for one account (or all), oldest first. */
|
||||
export async function readQueue(account?: string): Promise<{ patches: QueuedPatch[]; outbox: OutboxEntry[] }> {
|
||||
const records = account ? await idbGetAllByAccount<QueueRecord>('queue', account) : await idbGetAll<QueueRecord>('queue');
|
||||
records.sort((a, b) => (a.id ?? 0) - (b.id ?? 0));
|
||||
const patches: QueuedPatch[] = [];
|
||||
const outbox: OutboxEntry[] = [];
|
||||
for (const record of records) {
|
||||
try {
|
||||
if (record.kind === 'patch') {
|
||||
patches.push({ id: record.id!, account: record.account, item: await openJson<PatchItem>(record) });
|
||||
} else {
|
||||
const payload = await openJson<SendPayload>(record);
|
||||
outbox.push({ id: record.id!, account: record.account, createdAt: record.createdAt, ...payload });
|
||||
}
|
||||
} catch {
|
||||
// Sealed with a key that is gone; removed with the account.
|
||||
}
|
||||
}
|
||||
return { patches, outbox };
|
||||
}
|
||||
|
||||
export async function updateOutboxEntry(entry: OutboxEntry, patch: Partial<Pick<OutboxEntry, 'status' | 'error'>>): Promise<void> {
|
||||
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 }]);
|
||||
}
|
||||
|
||||
export async function removeQueued(ids: number[]): Promise<void> {
|
||||
await idbWrite(ids.map((id) => ({ store: 'queue' as const, del: id })));
|
||||
}
|
||||
|
||||
/** Removes an outbox entry and the files it held. */
|
||||
export async function removeOutboxEntry(entry: OutboxEntry): Promise<void> {
|
||||
const blobIds = heldBlobIds(entry.item);
|
||||
await idbWrite([
|
||||
{ store: 'queue', del: entry.id },
|
||||
...blobIds.map((blobId) => ({ store: 'uploads' as const, del: uploadKey(entry.account, blobId) })),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---- Files attached while offline ----
|
||||
|
||||
/** Stand-in blob ids of files kept on this device start with this. */
|
||||
export const LOCAL_BLOB_PREFIX = 'bulwark-local-';
|
||||
|
||||
const uploadKey = (account: string, blobId: string) => `${account}\u0001${blobId}`;
|
||||
|
||||
export function isLocalBlobId(blobId: unknown): blobId is string {
|
||||
return typeof blobId === 'string' && blobId.startsWith(LOCAL_BLOB_PREFIX);
|
||||
}
|
||||
|
||||
/** 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 } }]);
|
||||
return { blobId, size: file.size, type: file.type || 'application/octet-stream' };
|
||||
}
|
||||
|
||||
export async function heldFile(account: string, blobId: string): Promise<File | null> {
|
||||
const record = await idbGet<Sealed & { name: string; type: string }>('uploads', uploadKey(account, blobId));
|
||||
if (!record) return null;
|
||||
try {
|
||||
return new File([await openBytes(record)], record.name || 'attachment', { type: record.type || 'application/octet-stream' });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The stand-in blob ids an outbox entry needs uploaded first. */
|
||||
export function heldBlobIds(item: SendItem): string[] {
|
||||
if (item.kind === 'raw') return isLocalBlobId(item.blobId) ? [item.blobId] : [];
|
||||
const attachments = item.args[10];
|
||||
if (!Array.isArray(attachments)) return [];
|
||||
return attachments.map((a) => (a as { blobId?: unknown }).blobId).filter(isLocalBlobId);
|
||||
}
|
||||
|
||||
/** Files held for no outbox entry (attached offline to a message that was then discarded). */
|
||||
export async function dropOrphanFiles(account: string, olderThanMs = 24 * 60 * 60 * 1000): Promise<void> {
|
||||
const { outbox } = await readQueue(account);
|
||||
const needed = new Set(outbox.flatMap((entry) => heldBlobIds(entry.item)));
|
||||
const held = await idbGetAllByAccount<{ key: string; createdAt?: number }>('uploads', account);
|
||||
const cutoff = Date.now() - olderThanMs;
|
||||
const stale = held.filter((record) => {
|
||||
const blobId = record.key.split('\u0001')[1];
|
||||
return !needed.has(blobId) && (record.createdAt ?? 0) < cutoff;
|
||||
});
|
||||
await idbWrite(stale.map((record) => ({ store: 'uploads' as const, del: record.key })));
|
||||
}
|
||||
|
||||
/** The queue and held files of one account. */
|
||||
export async function wipeAccountQueue(account: string): Promise<void> {
|
||||
await idbDeleteAccount(account, ['queue', 'uploads']);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Answers JMAP requests from the mail kept on this device while the server
|
||||
* can't be reached (#523), so the app works as usual: the folder list, the
|
||||
* message list of a cached folder, opening a cached message, threads.
|
||||
*
|
||||
* Changes are queued (queue.ts) and applied to the cache at once: keywords
|
||||
* (read, flagged, any tag) and folders (move, delete to Trash). Deleting for
|
||||
* good, emptying a folder and everything else that would change the server
|
||||
* is refused, as are calls the cache can't answer (search, changes since a
|
||||
* state): the whole request then fails as it would without offline mode.
|
||||
*/
|
||||
|
||||
import { emailIndex, getBody, getMisc, patchEmails, type CachedEmail } from './cache';
|
||||
import type { OfflineMethodCall, OfflineResponse } from '@/lib/jmap/offline-hooks';
|
||||
|
||||
export type MethodResult = Record<string, unknown>;
|
||||
|
||||
export interface ResponderContext {
|
||||
account: string;
|
||||
/** The client's own JMAP account (calls without accountId use it). */
|
||||
defaultJmap: string;
|
||||
/** Queue changes to messages; resolves once they are kept. */
|
||||
queuePatch: (jmap: string, update: Record<string, Record<string, unknown>>) => Promise<void>;
|
||||
/** A change offline mode refuses (delete for good); the caller tells the user. */
|
||||
onRefused: (what: 'destroy' | 'other') => void;
|
||||
}
|
||||
|
||||
class Unanswerable extends Error {}
|
||||
|
||||
/** RFC 8620 §3.7 result references, with the `*` array step. */
|
||||
function resolvePointer(value: unknown, path: string): unknown {
|
||||
const steps = path.split('/').slice(1).map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~'));
|
||||
const walk = (node: unknown, i: number): unknown => {
|
||||
if (i === steps.length) return node;
|
||||
const step = steps[i];
|
||||
if (step === '*') {
|
||||
if (!Array.isArray(node)) throw new Unanswerable();
|
||||
return node.flatMap((item) => {
|
||||
const out = walk(item, i + 1);
|
||||
return Array.isArray(out) ? out : [out];
|
||||
});
|
||||
}
|
||||
if (node === null || typeof node !== 'object') throw new Unanswerable();
|
||||
return walk((node as Record<string, unknown>)[step], i + 1);
|
||||
};
|
||||
return walk(value, 0);
|
||||
}
|
||||
|
||||
function resolveArgs(args: Record<string, unknown>, done: Array<[string, MethodResult, string]>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
if (!key.startsWith('#')) {
|
||||
out[key] = value;
|
||||
continue;
|
||||
}
|
||||
const ref = value as { resultOf?: string; name?: string; path?: string };
|
||||
const source = done.find(([name, , id]) => id === ref.resultOf && name === ref.name);
|
||||
if (!source || typeof ref.path !== 'string') throw new Unanswerable();
|
||||
out[key.slice(1)] = resolvePointer(source[1], ref.path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Email/query filters and sort ----
|
||||
|
||||
type Filter = Record<string, unknown>;
|
||||
|
||||
function matches(email: CachedEmail, filter: Filter | undefined | null): boolean {
|
||||
if (!filter || Object.keys(filter).length === 0) return true;
|
||||
if (typeof filter.operator === 'string') {
|
||||
const conditions = Array.isArray(filter.conditions) ? (filter.conditions as Filter[]) : [];
|
||||
if (filter.operator === 'AND') return conditions.every((c) => matches(email, c));
|
||||
if (filter.operator === 'OR') return conditions.some((c) => matches(email, c));
|
||||
if (filter.operator === 'NOT') return !conditions.some((c) => matches(email, c));
|
||||
throw new Unanswerable();
|
||||
}
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
switch (key) {
|
||||
case 'inMailbox':
|
||||
if (!email.mailboxIds?.[value as string]) return false;
|
||||
break;
|
||||
case 'inMailboxOtherThan':
|
||||
if (!Object.keys(email.mailboxIds ?? {}).some((id) => !(value as string[]).includes(id))) return false;
|
||||
break;
|
||||
case 'hasKeyword':
|
||||
if (!email.keywords?.[value as string]) return false;
|
||||
break;
|
||||
case 'notKeyword':
|
||||
if (email.keywords?.[value as string]) return false;
|
||||
break;
|
||||
case 'after':
|
||||
if (Date.parse(String(email.receivedAt)) < Date.parse(String(value))) return false;
|
||||
break;
|
||||
case 'before':
|
||||
if (Date.parse(String(email.receivedAt)) >= Date.parse(String(value))) return false;
|
||||
break;
|
||||
case 'hasAttachment':
|
||||
if (Boolean(email.hasAttachment) !== Boolean(value)) return false;
|
||||
break;
|
||||
default:
|
||||
// Text search and the rest need the server.
|
||||
throw new Unanswerable();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface Comparator { property?: string; isAscending?: boolean; keyword?: string }
|
||||
|
||||
function firstAddress(value: unknown): string {
|
||||
const first = Array.isArray(value) ? (value[0] as { name?: string; email?: string } | undefined) : undefined;
|
||||
return (first?.name || first?.email || '').toLowerCase();
|
||||
}
|
||||
|
||||
function compareBy(sort: Comparator[]): (a: CachedEmail, b: CachedEmail) => number {
|
||||
const levels = sort.length > 0 ? sort : [{ property: 'receivedAt', isAscending: false }];
|
||||
return (a, b) => {
|
||||
for (const level of levels) {
|
||||
let diff = 0;
|
||||
switch (level.property) {
|
||||
case 'receivedAt':
|
||||
case 'sentAt':
|
||||
diff = (Date.parse(String(a[level.property] ?? a.receivedAt)) || 0) - (Date.parse(String(b[level.property] ?? b.receivedAt)) || 0);
|
||||
break;
|
||||
case 'size':
|
||||
diff = Number(a.size ?? 0) - Number(b.size ?? 0);
|
||||
break;
|
||||
case 'subject':
|
||||
diff = String(a.subject ?? '').localeCompare(String(b.subject ?? ''));
|
||||
break;
|
||||
case 'from':
|
||||
diff = firstAddress(a.from).localeCompare(firstAddress(b.from));
|
||||
break;
|
||||
case 'to':
|
||||
diff = firstAddress(a.to).localeCompare(firstAddress(b.to));
|
||||
break;
|
||||
case 'hasKeyword':
|
||||
diff = Number(Boolean(a.keywords?.[level.keyword ?? ''])) - Number(Boolean(b.keywords?.[level.keyword ?? '']));
|
||||
break;
|
||||
default:
|
||||
diff = 0;
|
||||
}
|
||||
if (diff !== 0) return level.isAscending === false ? -diff : diff;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
|
||||
async function emailsOf(ctx: ResponderContext, jmap: string): Promise<CachedEmail[]> {
|
||||
const index = await emailIndex(ctx.account);
|
||||
const prefix = `${ctx.account}\u0001${jmap}\u0001`;
|
||||
const out: CachedEmail[] = [];
|
||||
for (const [key, email] of index) if (key.startsWith(prefix)) out.push(email);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function emailQuery(ctx: ResponderContext, args: Record<string, unknown>): Promise<MethodResult> {
|
||||
const jmap = (args.accountId as string) || ctx.defaultJmap;
|
||||
if (args.anchor !== undefined) 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) {
|
||||
const seen = new Set<string>();
|
||||
list = list.filter((email) => {
|
||||
const thread = email.threadId ?? email.id;
|
||||
if (seen.has(thread)) return false;
|
||||
seen.add(thread);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
const position = Math.max(0, Number(args.position ?? 0));
|
||||
const limit = typeof args.limit === 'number' ? args.limit : list.length;
|
||||
return {
|
||||
accountId: jmap,
|
||||
queryState: 'offline',
|
||||
canCalculateChanges: false,
|
||||
position,
|
||||
ids: list.slice(position, position + limit).map((email) => email.id),
|
||||
total: list.length,
|
||||
};
|
||||
}
|
||||
|
||||
function pick(email: Record<string, unknown>, properties: unknown): Record<string, unknown> {
|
||||
if (!Array.isArray(properties)) return email;
|
||||
const out: Record<string, unknown> = { id: email.id };
|
||||
for (const prop of properties as string[]) if (prop in email) out[prop] = email[prop];
|
||||
return out;
|
||||
}
|
||||
|
||||
const NEEDS_BODY = new Set(['bodyValues', 'textBody', 'htmlBody', 'bodyStructure', 'headers', 'attachments']);
|
||||
|
||||
async function emailGet(ctx: ResponderContext, args: Record<string, unknown>): Promise<MethodResult> {
|
||||
const jmap = (args.accountId as string) || ctx.defaultJmap;
|
||||
const index = await emailIndex(ctx.account);
|
||||
const ids = Array.isArray(args.ids) ? (args.ids as string[]) : null;
|
||||
if (!ids) throw new Unanswerable();
|
||||
const properties = args.properties as string[] | undefined;
|
||||
const wantsBody = args.fetchTextBodyValues || args.fetchHTMLBodyValues || args.fetchAllBodyValues
|
||||
|| !properties || properties.some((p) => NEEDS_BODY.has(p));
|
||||
const list: Record<string, unknown>[] = [];
|
||||
const notFound: string[] = [];
|
||||
for (const id of ids) {
|
||||
const meta = index.get(`${ctx.account}\u0001${jmap}\u0001${id}`);
|
||||
if (!meta) { notFound.push(id); continue; }
|
||||
if (!wantsBody) { list.push(pick(meta, properties)); continue; }
|
||||
const body = await getBody(ctx.account, jmap, id);
|
||||
// Asked for the text and it isn't here: as if the message weren't
|
||||
// there, rather than an empty message.
|
||||
if (!body && (args.fetchTextBodyValues || args.fetchHTMLBodyValues || args.fetchAllBodyValues || properties?.includes('bodyValues'))) {
|
||||
notFound.push(id);
|
||||
continue;
|
||||
}
|
||||
list.push(pick({ ...(body ?? {}), ...meta }, properties));
|
||||
}
|
||||
return { accountId: jmap, state: 'offline', list, notFound };
|
||||
}
|
||||
|
||||
async function threadGet(ctx: ResponderContext, args: Record<string, unknown>): Promise<MethodResult> {
|
||||
const jmap = (args.accountId as string) || ctx.defaultJmap;
|
||||
const ids = Array.isArray(args.ids) ? (args.ids as string[]) : [];
|
||||
const emails = await emailsOf(ctx, jmap);
|
||||
const list = [...new Set(ids)].map((id) => ({
|
||||
id,
|
||||
emailIds: emails
|
||||
.filter((email) => email.threadId === id)
|
||||
.sort((a, b) => (Date.parse(String(a.receivedAt)) || 0) - (Date.parse(String(b.receivedAt)) || 0))
|
||||
.map((email) => email.id),
|
||||
}));
|
||||
return { accountId: jmap, state: 'offline', list: list.filter((t) => t.emailIds.length > 0), notFound: list.filter((t) => t.emailIds.length === 0).map((t) => t.id) };
|
||||
}
|
||||
|
||||
async function listGet(ctx: ResponderContext, args: Record<string, unknown>, name: 'mailboxes' | 'identities'): Promise<MethodResult> {
|
||||
const jmap = (args.accountId as string) || ctx.defaultJmap;
|
||||
const all = await getMisc<Record<string, Record<string, unknown>[]>>(ctx.account, name);
|
||||
const list = all?.[jmap];
|
||||
if (!list) throw new Unanswerable();
|
||||
const ids = Array.isArray(args.ids) ? new Set(args.ids as string[]) : null;
|
||||
const chosen = ids ? list.filter((item) => ids.has(item.id as string)) : list;
|
||||
return {
|
||||
accountId: jmap,
|
||||
state: 'offline',
|
||||
list: chosen.map((item) => pick(item, args.properties)),
|
||||
notFound: ids ? [...ids].filter((id) => !list.some((item) => item.id === id)) : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a PatchObject only changes what offline mode may queue. */
|
||||
function queueablePatch(patch: Record<string, unknown>): boolean {
|
||||
return Object.keys(patch).length > 0 && Object.keys(patch).every((path) =>
|
||||
path === 'keywords' || path === 'mailboxIds' || path.startsWith('keywords/') || path.startsWith('mailboxIds/'));
|
||||
}
|
||||
|
||||
async function emailSet(ctx: ResponderContext, args: Record<string, unknown>): Promise<MethodResult> {
|
||||
const jmap = (args.accountId as string) || ctx.defaultJmap;
|
||||
// A new message (a draft saved while offline) needs the server; the
|
||||
// composer says the draft wasn't saved, so no notice here.
|
||||
if (args.create && Object.keys(args.create as object).length > 0) throw new Unanswerable();
|
||||
if (Array.isArray(args.destroy) && args.destroy.length > 0) { ctx.onRefused('destroy'); throw new Unanswerable(); }
|
||||
const update = (args.update ?? {}) as Record<string, Record<string, unknown>>;
|
||||
if (!Object.values(update).every(queueablePatch)) { ctx.onRefused('other'); throw new Unanswerable(); }
|
||||
// A message moved out of every folder would be gone: that is a delete.
|
||||
const index = await emailIndex(ctx.account);
|
||||
for (const [id, patch] of Object.entries(update)) {
|
||||
if ('mailboxIds' in patch && Object.keys((patch.mailboxIds as object) ?? {}).length === 0) { ctx.onRefused('destroy'); throw new Unanswerable(); }
|
||||
const current = index.get(`${ctx.account}\u0001${jmap}\u0001${id}`);
|
||||
if (current && Object.keys(patch).some((p) => p.startsWith('mailboxIds/'))) {
|
||||
const after = new Set(Object.keys(current.mailboxIds ?? {}));
|
||||
for (const [path, value] of Object.entries(patch)) {
|
||||
if (!path.startsWith('mailboxIds/')) continue;
|
||||
const box = path.slice('mailboxIds/'.length);
|
||||
if (value) after.add(box); else after.delete(box);
|
||||
}
|
||||
if (after.size === 0) { ctx.onRefused('destroy'); throw new Unanswerable(); }
|
||||
}
|
||||
}
|
||||
await ctx.queuePatch(jmap, update);
|
||||
await patchEmails(ctx.account, jmap, update);
|
||||
return {
|
||||
accountId: jmap,
|
||||
oldState: 'offline',
|
||||
newState: 'offline',
|
||||
updated: Object.fromEntries(Object.keys(update).map((id) => [id, null])),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer to a whole request from this device, or null when any call in
|
||||
* it needs the server.
|
||||
*/
|
||||
export async function answerOffline(ctx: ResponderContext, methodCalls: OfflineMethodCall[]): Promise<OfflineResponse | null> {
|
||||
const done: Array<[string, MethodResult, string]> = [];
|
||||
try {
|
||||
for (const [name, rawArgs, callId] of methodCalls) {
|
||||
const args = resolveArgs(rawArgs, done);
|
||||
let result: MethodResult;
|
||||
switch (name) {
|
||||
case 'Email/query': result = await emailQuery(ctx, args); break;
|
||||
case 'Email/get': result = await emailGet(ctx, args); break;
|
||||
case 'Thread/get': result = await threadGet(ctx, args); break;
|
||||
case 'Mailbox/get': result = await listGet(ctx, args, 'mailboxes'); break;
|
||||
case 'Identity/get': result = await listGet(ctx, args, 'identities'); break;
|
||||
case 'Email/set': result = await emailSet(ctx, args); break;
|
||||
default: throw new Unanswerable();
|
||||
}
|
||||
done.push([name, result, callId]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Unanswerable) return null;
|
||||
throw error;
|
||||
}
|
||||
return { methodResponses: done };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* The app shell for offline mode (#523): tells the service worker
|
||||
* (public/sw.js) to keep this build's pages and files while any account
|
||||
* keeps mail on this device, and to drop them when none does.
|
||||
*/
|
||||
|
||||
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
import { 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<string[]> {
|
||||
try {
|
||||
const res = await apiFetch('/api/offline/shell', { cache: 'no-store' });
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { files?: unknown };
|
||||
return Array.isArray(data.files) ? data.files.filter((f): f is string => typeof f === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function activeWorker(): Promise<ServiceWorker | null> {
|
||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return null;
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
|
||||
return registration?.active ?? navigator.serviceWorker.controller ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function tellServiceWorker(on: boolean): Promise<void> {
|
||||
const worker = await activeWorker();
|
||||
if (!worker) return;
|
||||
const urls = on ? [...SHELL_PAGES.map((p) => withBasePath(p)), ...(await shellFiles())] : [];
|
||||
worker.postMessage({ type: 'offline-shell', on, urls });
|
||||
}
|
||||
|
||||
/** The service worker's copies of the shell, from the page (also when no worker runs). */
|
||||
export async function deleteShellCaches(): Promise<void> {
|
||||
if (typeof caches === 'undefined') return;
|
||||
try {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.filter((key) => key.startsWith('bulwark-shell-')).map((key) => caches.delete(key)));
|
||||
} catch {
|
||||
/* no Cache API */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* What offline mode (#523) shows: which accounts keep mail on this device,
|
||||
* whether the browser is offline, what waits in the outbox, how much the
|
||||
* cache uses. The switch itself ("Keep mail on this device") is per account
|
||||
* and per browser, in localStorage, never synced: it is off until the user
|
||||
* turns it on here.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { OutboxEntry } from './queue';
|
||||
|
||||
/** localStorage: the local account ids that keep mail on this device. */
|
||||
export const OFFLINE_ACCOUNTS_KEY = 'bulwark-offline-accounts';
|
||||
|
||||
export interface OutboxRow {
|
||||
id: number;
|
||||
account: string;
|
||||
createdAt: number;
|
||||
subject: string;
|
||||
to: string[];
|
||||
status: OutboxEntry['status'];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface OfflineState {
|
||||
/** Accounts with "Keep mail on this device" on. */
|
||||
enabled: string[];
|
||||
/** The browser says it has no network. */
|
||||
browserOffline: boolean;
|
||||
/** The last request to the mail server failed on the network. */
|
||||
networkDown: boolean;
|
||||
/** The app started without the server, from the mail on this device. */
|
||||
startedOffline: boolean;
|
||||
outbox: OutboxRow[];
|
||||
pendingChanges: number;
|
||||
usageBytes: number;
|
||||
/** Accounts whose mail is being brought onto the device right now. */
|
||||
syncing: string[];
|
||||
lastSyncedAt: Record<string, number>;
|
||||
}
|
||||
|
||||
function readEnabled(): string[] {
|
||||
try {
|
||||
const raw = typeof localStorage === 'undefined' ? null : localStorage.getItem(OFFLINE_ACCOUNTS_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const useOfflineStore = create<OfflineState>()(() => ({
|
||||
enabled: readEnabled(),
|
||||
browserOffline: typeof navigator !== 'undefined' && navigator.onLine === false,
|
||||
networkDown: false,
|
||||
startedOffline: false,
|
||||
outbox: [],
|
||||
pendingChanges: 0,
|
||||
usageBytes: 0,
|
||||
syncing: [],
|
||||
lastSyncedAt: {},
|
||||
}));
|
||||
|
||||
export function writeEnabled(enabled: string[]): void {
|
||||
useOfflineStore.setState({ enabled });
|
||||
try {
|
||||
if (enabled.length) localStorage.setItem(OFFLINE_ACCOUNTS_KEY, JSON.stringify(enabled));
|
||||
else localStorage.removeItem(OFFLINE_ACCOUNTS_KEY);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
/** The browser is offline, or the mail server couldn't be reached. */
|
||||
export function isOfflineNow(): boolean {
|
||||
const { browserOffline, networkDown } = useOfflineStore.getState();
|
||||
return browserOffline || networkDown;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Brings an account's recent mail onto the device (#523): the last 30 days
|
||||
* of the Inbox and of the folders the user opened (at most INBOX_LIMIT and
|
||||
* FOLDER_LIMIT messages), with bodies, and the attachments and inline
|
||||
* images of those messages up to ATTACHMENT_MAX_BYTES each. Messages that
|
||||
* left that window or the folders are removed; then the cache is brought
|
||||
* under its cap, oldest first.
|
||||
*
|
||||
* Everything goes through the JMAP client, whose answers the cache records
|
||||
* (offline-mode.ts), so the sync only asks.
|
||||
*/
|
||||
|
||||
import type { JMAPClient } from '@/lib/jmap/client';
|
||||
import { CACHE_WINDOW_MS, emailIndex, emailKey, enforceCap, getBody, hasBlob, hasBody, putBlob, putMisc, removeEmails, type CachedEmail } from './cache';
|
||||
|
||||
export const INBOX_LIMIT = 500;
|
||||
export const FOLDER_LIMIT = 200;
|
||||
export const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024;
|
||||
/** Attachment bytes one sync run fetches at most. */
|
||||
const ATTACHMENT_BUDGET_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
interface SyncDeps {
|
||||
stillActive: () => boolean;
|
||||
scopeMailboxes: (account: string) => Promise<Array<{ jmap: string; mailboxId: string; isInbox: boolean }>>;
|
||||
}
|
||||
|
||||
interface AttachmentPart {
|
||||
blobId?: string;
|
||||
name?: string | null;
|
||||
type?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export async function syncAccount(account: string, client: JMAPClient, deps: SyncDeps): Promise<void> {
|
||||
await putMisc(account, 'session', client.getSessionSnapshot());
|
||||
// Folders and identities are recorded from these answers.
|
||||
await client.getAllMailboxes().catch(() => client.getMailboxes());
|
||||
await client.getIdentities().catch(() => []);
|
||||
if (!deps.stillActive()) return;
|
||||
|
||||
const since = new Date(Date.now() - CACHE_WINDOW_MS).toISOString();
|
||||
const wanted = new Map<string, Set<string>>();
|
||||
const scopes = await deps.scopeMailboxes(account);
|
||||
for (const { jmap, mailboxId, isInbox } of scopes) {
|
||||
let ids: string[];
|
||||
try {
|
||||
ids = await client.getRecentEmailIds(mailboxId, jmap, since, isInbox ? INBOX_LIMIT : FOLDER_LIMIT);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const set = wanted.get(jmap) ?? new Set<string>();
|
||||
ids.forEach((id) => set.add(id));
|
||||
wanted.set(jmap, set);
|
||||
}
|
||||
if (!deps.stillActive()) return;
|
||||
|
||||
for (const [jmap, idSet] of wanted) {
|
||||
const ids = [...idSet];
|
||||
// Fresh keywords and folders for all of them (cheap: list properties).
|
||||
if (ids.length) await client.getSomeEmails(ids, jmap === client.getAccountId() ? undefined : jmap).catch(() => []);
|
||||
// Bodies for the ones that don't have one yet.
|
||||
const missing: string[] = [];
|
||||
for (const id of ids) if (!(await hasBody(account, jmap, id))) missing.push(id);
|
||||
if (missing.length) await client.getFullEmails(missing, jmap);
|
||||
if (!deps.stillActive()) return;
|
||||
}
|
||||
|
||||
// What left the window or the folders goes.
|
||||
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);
|
||||
}
|
||||
// A folder that couldn't be asked keeps what it had.
|
||||
if (scopes.length > 0 && wanted.size > 0) await removeEmails(account, stale);
|
||||
|
||||
// Attachments and inline images, newest messages first, within the budget.
|
||||
let budget = ATTACHMENT_BUDGET_BYTES;
|
||||
const newestFirst = [...index.entries()].sort(([, a], [, b]) => (Date.parse(String(b.receivedAt)) || 0) - (Date.parse(String(a.receivedAt)) || 0));
|
||||
for (const [key, email] of newestFirst) {
|
||||
if (budget <= 0 || !deps.stillActive()) break;
|
||||
const jmap = key.split('\u0001')[1];
|
||||
for (const part of await attachmentsOf(account, email, jmap)) {
|
||||
if (!part.blobId || !part.size || part.size > ATTACHMENT_MAX_BYTES || part.size > budget) continue;
|
||||
if (await hasBlob(account, jmap, part.blobId)) continue;
|
||||
try {
|
||||
const blob = await client.fetchBlob(part.blobId, part.name ?? undefined, part.type, jmap);
|
||||
await putBlob(account, jmap, part.blobId, blob, Date.parse(String(email.receivedAt)) || Date.now(), emailKey(account, jmap, email.id));
|
||||
budget -= blob.size;
|
||||
} catch {
|
||||
// Fetched again at the next sync.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await enforceCap();
|
||||
}
|
||||
|
||||
async function attachmentsOf(account: string, email: CachedEmail, jmap: string): Promise<AttachmentPart[]> {
|
||||
const body = await getBody(account, jmap, email.id);
|
||||
const parts = body?.attachments;
|
||||
return Array.isArray(parts) ? (parts as AttachmentPart[]) : [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Whether a plugin's raw send (api.jmap.sendRaw / submitRaw) completes a
|
||||
* send the composer handed it (onComposeSend: S/MIME, PGP). Such a send ran
|
||||
* 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.
|
||||
*/
|
||||
|
||||
let open = 0;
|
||||
|
||||
/** Called by the composer right before it hands the send to the plugins; pair with endComposeTakeover. */
|
||||
export function beginComposeTakeover(): void {
|
||||
open += 1;
|
||||
}
|
||||
|
||||
export function endComposeTakeover(): void {
|
||||
open = Math.max(0, open - 1);
|
||||
}
|
||||
|
||||
export function composeTakeoverOpen(): boolean {
|
||||
return open > 0;
|
||||
}
|
||||
@@ -18,8 +18,11 @@ export interface ComposerSendExtras {
|
||||
* so none drops the read receipt, DSN, REQUIRETLS, priority, plugin headers
|
||||
* or the classification label: the Pro tab used to send without them.
|
||||
*/
|
||||
export function composerSendOptions(data: ComposerSendExtras): ComposerSendExtras {
|
||||
export function composerSendOptions(data: ComposerSendExtras): ComposerSendExtras & { outbox: true } {
|
||||
return {
|
||||
// Every step of the send ran at Send, so offline mode may keep the
|
||||
// finished message in its outbox (#523).
|
||||
outbox: true,
|
||||
requestReadReceipt: data.requestReadReceipt,
|
||||
requestDsn: data.requestDsn,
|
||||
requireTls: data.requireTls,
|
||||
|
||||
@@ -49,6 +49,8 @@ function emit(payload: AfterSendPayload): void {
|
||||
|
||||
/** Report a send the server accepted (see the rules above). */
|
||||
export function announceSent(result: SendEmailResult, message: SentMessage): void {
|
||||
// Waiting in the offline outbox (#523): nothing left yet.
|
||||
if (result.queued) return;
|
||||
const payload: AfterSendPayload = {
|
||||
emailId: result.emailId ?? null,
|
||||
accountId: result.emailAccountId ?? null,
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
import type { AppInfoView, InstalledPlugin, Permission } from '../plugin-types';
|
||||
import { labelRawSend } from '@/lib/outgoing/classification-raw';
|
||||
import { composeTakeoverOpen } from '@/lib/outgoing/compose-takeover';
|
||||
import { getOutboxHooks } from '@/lib/offline/outbox-hooks';
|
||||
import { fetchConfig } from '@/hooks/use-config';
|
||||
import { IS_LITE } from '../lite';
|
||||
import { downloadAllowed, saveBlobAs } from '../download-guard';
|
||||
@@ -840,6 +842,8 @@ async function doJmapSendRaw(
|
||||
identityId,
|
||||
opts?.delayedUntil,
|
||||
opts?.envelopeRecipients,
|
||||
// Completing the composer's send: offline mode may keep it (#523).
|
||||
{ outbox: composeTakeoverOpen() },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -870,6 +874,12 @@ async function doJmapSubmitRaw(
|
||||
const labelled = labelRawSend(copy, identityId, opts?.labelToken);
|
||||
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;
|
||||
if (outbox?.shouldQueue(client)) {
|
||||
return outbox.queueRaw(client, blob, { identityId, delayedUntil: opts?.delayedUntil, envelopeRecipients: opts?.envelopeRecipients, submitOnly: true });
|
||||
}
|
||||
|
||||
return client.submitRawEmail(
|
||||
blob,
|
||||
identityId,
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
export type SessionEndReason =
|
||||
| { kind: 'idle'; minutes: number; draftsUnsaved?: boolean }
|
||||
| { kind: 'max'; hours: number; draftsUnsaved?: boolean };
|
||||
| { kind: 'idle'; minutes: number; draftsUnsaved?: boolean; outboxLost?: string[] }
|
||||
| { kind: 'max'; hours: number; draftsUnsaved?: boolean; outboxLost?: string[] };
|
||||
|
||||
const KEY = 'session_end_reason';
|
||||
|
||||
@@ -17,6 +17,23 @@ 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<string, unknown>;
|
||||
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 {
|
||||
@@ -33,7 +50,11 @@ export function consumeSessionEnd(): SessionEndReason | null {
|
||||
window.sessionStorage.removeItem(KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const drafts = parsed.draftsUnsaved === true ? { draftsUnsaved: true } : {};
|
||||
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 } : {}),
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -31,6 +31,9 @@ const SIGNED_OUT_STORAGE_KEYS = [
|
||||
// The last organisation policy (stores/policy-store.ts POLICY_CACHE_KEY):
|
||||
// its scopes, labels and notices are the signed-in user's.
|
||||
'bulwark-policy-last',
|
||||
// Offline mode's "Keep mail on this device" (lib/offline/state.ts): the
|
||||
// next person signing in here starts with it off.
|
||||
'bulwark-offline-accounts',
|
||||
];
|
||||
|
||||
const SIGNED_OUT_SIGNAL = 'bulwark:signed-out';
|
||||
@@ -54,6 +57,28 @@ export function onBeforeSignOut(task: (accountId: string) => Promise<void>): ()
|
||||
return () => beforeSignOutTasks.delete(task);
|
||||
}
|
||||
|
||||
// Questions a sign-out the user asked for has to pass first, such as "2
|
||||
// messages haven't been sent" (offline mode). An automatic sign-out (the
|
||||
// session limits) doesn't ask.
|
||||
const signOutGates = new Set<(accountId: string) => Promise<boolean>>();
|
||||
|
||||
/** Ask before a sign-out the user started; false from `gate` keeps everyone signed in. */
|
||||
export function addSignOutGate(gate: (accountId: string) => Promise<boolean>): () => void {
|
||||
signOutGates.add(gate);
|
||||
return () => signOutGates.delete(gate);
|
||||
}
|
||||
|
||||
export async function passSignOutGates(accountId: string): Promise<boolean> {
|
||||
for (const gate of [...signOutGates]) {
|
||||
try {
|
||||
if (!(await gate(accountId))) return false;
|
||||
} catch {
|
||||
// A broken question doesn't keep anyone signed in.
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function runBeforeSignOut(accountId: string): Promise<void> {
|
||||
await Promise.all([...beforeSignOutTasks].map((task) => task(accountId).catch(() => {})));
|
||||
}
|
||||
@@ -81,6 +106,14 @@ export function purgeSignedOutData(): void {
|
||||
void clearAllCheckpoints().catch(() => {});
|
||||
// Mail exports written to the browser's private files and not deleted yet.
|
||||
void sweepPrivateExports({ force: true }).catch(() => {});
|
||||
// Offline mode (#523): the mail kept on this device, the outbox, the key
|
||||
// they were encrypted with, and the app shell the service worker kept.
|
||||
try {
|
||||
window.indexedDB?.deleteDatabase('bulwark-offline');
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
forgetOfflineShell();
|
||||
for (const cleanup of signedOutCleanups) {
|
||||
try {
|
||||
cleanup();
|
||||
@@ -90,6 +123,25 @@ export function purgeSignedOutData(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** The service worker stops keeping the app shell and its copies go. */
|
||||
function forgetOfflineShell(): void {
|
||||
try {
|
||||
navigator.serviceWorker?.controller?.postMessage({ type: 'offline-shell', on: false });
|
||||
} catch {
|
||||
/* no service worker */
|
||||
}
|
||||
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))))
|
||||
.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(() => {});
|
||||
} catch {
|
||||
/* no Cache API */
|
||||
}
|
||||
}
|
||||
|
||||
/** Tell the other tabs of this browser that everyone signed out. */
|
||||
export function broadcastSignOut(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "إصدار جديد من {appName} جاهز.",
|
||||
"reload": "إعادة التحميل"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "دون اتصال",
|
||||
"description": "اقرأ البريد واكتبه على هذا الجهاز دون اتصال.",
|
||||
"keep_label": "الاحتفاظ بالبريد على هذا الجهاز",
|
||||
"keep_description": "يحتفظ بآخر 30 يومًا من البريد الوارد ومن المجلدات التي تفتحها، حتى 250 ميغابايت، مشفّرة في هذا المتصفح. دون اتصال يمكنك قراءة البريد ووضع علامات عليه ونقله، وما تكتبه ينتظر في صندوق الصادر. يؤدي تسجيل الخروج إلى إزالة كل ذلك.",
|
||||
"warning": "يستطيع أي شخص يمكنه استخدام ملف تعريف المتصفح هذا قراءة هذا البريد. فعّل هذا على جهازك فقط واستخدم تشفير القرص.",
|
||||
"forbidden": "لا تسمح مؤسستك بالاحتفاظ بالبريد على الأجهزة.",
|
||||
"unsupported": "لا يستطيع هذا المتصفح الاحتفاظ بالبريد للاستخدام دون اتصال.",
|
||||
"syncing": "جارٍ حفظ بريدك على هذا الجهاز…",
|
||||
"usage": "{size} على هذا الجهاز",
|
||||
"usage_updated": "{size} على هذا الجهاز، حُدّث {time}",
|
||||
"off_confirm_title": "إيقاف وضع عدم الاتصال؟",
|
||||
"off_confirm_message": "{count, plural, zero {لا توجد رسائل غير مرسلة في صندوق الصادر.} one {رسالة واحدة في صندوق الصادر لم تُرسل.} two {رسالتان في صندوق الصادر لم تُرسلا.} few {# رسائل في صندوق الصادر لم تُرسل.} many {# رسالة في صندوق الصادر لم تُرسل.} other {# رسالة في صندوق الصادر لم تُرسل.}} يؤدي الإيقاف إلى حذفها مع البريد المحفوظ على هذا الجهاز.",
|
||||
"off_confirm": "إيقاف"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "صندوق الصادر",
|
||||
"description": "رسائل كتبتها دون اتصال. تُرسل عندما يعود هذا الجهاز إلى الاتصال.",
|
||||
"empty": "لا شيء ينتظر الإرسال.",
|
||||
"no_subject": "(بلا موضوع)",
|
||||
"to": "إلى: {recipients}",
|
||||
"status_waiting": "في انتظار الاتصال",
|
||||
"status_sending": "قيد الإرسال",
|
||||
"status_failed": "تعذّر الإرسال: {error}",
|
||||
"status_uncertain": "انقطع الاتصال أثناء الإرسال. تحقّق من المرسل قبل أن ترسلها مرة أخرى.",
|
||||
"retry": "إرسال مرة أخرى",
|
||||
"delete": "حذف",
|
||||
"delete_confirm_title": "حذف هذه الرسالة؟",
|
||||
"delete_confirm_message": "لم تُرسل ولا يمكن استعادتها.",
|
||||
"changes": "{count, plural, zero {لا تغييرات} one {تغيير واحد} two {تغييران} few {# تغييرات} many {# تغييرًا} other {# تغيير}} على حالة القراءة أو العلامات أو المجلدات ستُرسل عند عودة الاتصال."
|
||||
},
|
||||
"banner": {
|
||||
"offline": "أنت دون اتصال. ترى البريد المحفوظ على هذا الجهاز. ما تغيّره أو تكتبه يُرسل عند عودة الاتصال.",
|
||||
"outbox": "{count, plural, zero {لا رسائل} one {رسالة واحدة} two {رسالتان} few {# رسائل} many {# رسالة} other {# رسالة}} في صندوق الصادر"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "حُفظت في صندوق الصادر. ستُرسل عند عودة الاتصال.",
|
||||
"sent": "أُرسلت من صندوق الصادر: {subject}",
|
||||
"send_failed": "تعذّر إرسال رسالة من صندوق الصادر: {subject}",
|
||||
"refused_delete": "الحذف النهائي يحتاج إلى اتصال. حاول مرة أخرى عندما تكون متصلًا.",
|
||||
"refused_other": "يحتاج هذا إلى اتصال. حاول مرة أخرى عندما تكون متصلًا.",
|
||||
"forbidden": "أوقفت مؤسستك وضع عدم الاتصال. أُزيل البريد المحفوظ على هذا الجهاز.",
|
||||
"back_online": "عاد الاتصال. أعد التحميل لتسجيل الدخول وإرسال ما ينتظر.",
|
||||
"reload": "إعادة التحميل"
|
||||
},
|
||||
"signout": {
|
||||
"title": "تسجيل الخروج مع رسائل غير مرسلة؟",
|
||||
"message": "{count, plural, zero {لا رسائل غير مرسلة.} one {رسالة واحدة لم تُرسل.} two {رسالتان لم تُرسلا.} few {# رسائل لم تُرسل.} many {# رسالة لم تُرسل.} other {# رسالة لم تُرسل.}} يؤدي تسجيل الخروج إلى حذفها.",
|
||||
"confirm": "تسجيل الخروج",
|
||||
"lost": "لم تُرسل هذه الرسائل وحُذفت عند تسجيل خروجك: {subjects}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "لم تعد هذه الرسالة متاحة.",
|
||||
"thread_not_found": "لم تعد هذه المحادثة متاحة.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "Hi ha una versió nova de {appName} a punt.",
|
||||
"reload": "Torna a carregar"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Sense connexió",
|
||||
"description": "Llegiu i escriviu correu en aquest dispositiu sense connexió.",
|
||||
"keep_label": "Desa el correu en aquest dispositiu",
|
||||
"keep_description": "Desa els darrers 30 dies de la safata d'entrada i de les carpetes que obriu, fins a 250 MB, xifrats en aquest navegador. Sense connexió podeu llegir, marcar i moure correu, i el que escriviu espera a la safata de sortida. En tancar la sessió s'esborra tot.",
|
||||
"warning": "Qualsevol persona que pugui fer servir aquest perfil del navegador pot llegir aquest correu. Activeu-ho només al vostre dispositiu i feu servir xifratge de disc.",
|
||||
"forbidden": "La vostra organització no permet desar correu als dispositius.",
|
||||
"unsupported": "Aquest navegador no pot desar correu per fer-lo servir sense connexió.",
|
||||
"syncing": "S'està desant el correu en aquest dispositiu…",
|
||||
"usage": "{size} en aquest dispositiu",
|
||||
"usage_updated": "{size} en aquest dispositiu, actualitzat {time}",
|
||||
"off_confirm_title": "Voleu desactivar el mode sense connexió?",
|
||||
"off_confirm_message": "{count, plural, one {# missatge de la safata de sortida no s'ha enviat.} other {# missatges de la safata de sortida no s'han enviat.}} Si el desactiveu, s'esborren juntament amb el correu desat en aquest dispositiu.",
|
||||
"off_confirm": "Desactiva"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Safata de sortida",
|
||||
"description": "Missatges que heu escrit sense connexió. S'envien quan aquest dispositiu torna a tenir connexió.",
|
||||
"empty": "No hi ha res pendent d'enviar.",
|
||||
"no_subject": "(sense assumpte)",
|
||||
"to": "Per a: {recipients}",
|
||||
"status_waiting": "S'espera connexió",
|
||||
"status_sending": "S'està enviant",
|
||||
"status_failed": "No s'ha pogut enviar: {error}",
|
||||
"status_uncertain": "La connexió s'ha tallat durant l'enviament. Comproveu Enviats abans de tornar-lo a enviar.",
|
||||
"retry": "Torna a enviar",
|
||||
"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ó."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Desat a la safata de sortida. S'envia quan torneu a tenir connexió.",
|
||||
"sent": "Enviat des de la safata de sortida: {subject}",
|
||||
"send_failed": "No s'ha pogut enviar un missatge de la safata de sortida: {subject}",
|
||||
"refused_delete": "Suprimir definitivament necessita connexió. Torneu-ho a provar quan tingueu connexió.",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Aquest missatge ja no està disponible.",
|
||||
"thread_not_found": "Aquesta conversa ja no està disponible.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "Je připravena nová verze aplikace {appName}.",
|
||||
"reload": "Načíst znovu"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Offline",
|
||||
"description": "Čtěte a pište poštu na tomto zařízení bez připojení.",
|
||||
"keep_label": "Uchovávat poštu na tomto zařízení",
|
||||
"keep_description": "Uchovává posledních 30 dní doručené pošty a složek, které otevřete, až 250 MB, zašifrované v tomto prohlížeči. Offline můžete poštu číst, označovat a přesouvat a to, co napíšete, čeká v k odeslání. Odhlášení vše odstraní.",
|
||||
"warning": "Kdokoli, kdo může používat tento profil prohlížeče, si tuto poštu může přečíst. Zapněte to jen na svém zařízení a používejte šifrování disku.",
|
||||
"forbidden": "Vaše organizace nepovoluje uchovávat poštu na zařízeních.",
|
||||
"unsupported": "Tento prohlížeč nemůže uchovávat poštu pro práci offline.",
|
||||
"syncing": "Ukládání pošty do tohoto zařízení…",
|
||||
"usage": "{size} na tomto zařízení",
|
||||
"usage_updated": "{size} na tomto zařízení, aktualizováno {time}",
|
||||
"off_confirm_title": "Vypnout režim offline?",
|
||||
"off_confirm_message": "{count, plural, one {# zpráva k odeslání nebyla odeslána.} few {# zprávy k odeslání nebyly odeslány.} many {# zprávy k odeslání nebylo odesláno.} other {# zpráv k odeslání nebylo odesláno.}} Vypnutím se smažou spolu s poštou uchovanou na tomto zařízení.",
|
||||
"off_confirm": "Vypnout"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "K odeslání",
|
||||
"description": "Zprávy napsané offline. Odešlou se, až bude zařízení znovu online.",
|
||||
"empty": "Nic nečeká na odeslání.",
|
||||
"no_subject": "(bez předmětu)",
|
||||
"to": "Komu: {recipients}",
|
||||
"status_waiting": "Čeká na připojení",
|
||||
"status_sending": "Odesílá se",
|
||||
"status_failed": "Nelze odeslat: {error}",
|
||||
"status_uncertain": "Připojení se při odesílání přerušilo. Než ji odešlete znovu, podívejte se do Odeslaných.",
|
||||
"retry": "Odeslat znovu",
|
||||
"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."
|
||||
},
|
||||
"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í"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Uloženo k odeslání. Odešle se, až budete znovu online.",
|
||||
"sent": "Odesláno z fronty k odeslání: {subject}",
|
||||
"send_failed": "Zprávu k odeslání se nepodařilo odeslat: {subject}",
|
||||
"refused_delete": "Trvalé smazání vyžaduje připojení. Zkuste to znovu, až budete online.",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Tato zpráva už není dostupná.",
|
||||
"thread_not_found": "Tato konverzace už není dostupná.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "En ny version af {appName} er klar.",
|
||||
"reload": "Genindlæs"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Offline",
|
||||
"description": "Læs og skriv mail på denne enhed uden forbindelse.",
|
||||
"keep_label": "Behold mail på denne enhed",
|
||||
"keep_description": "Beholder de seneste 30 dage af din indbakke og af de mapper, du åbner, op til 250 MB, krypteret i denne browser. Offline kan du læse, markere og flytte mail, og det, du skriver, venter i udbakken. Når du logger ud, fjernes det hele.",
|
||||
"warning": "Alle, der kan bruge denne browserprofil, kan læse disse mails. Slå det kun til på din egen enhed, og brug diskkryptering.",
|
||||
"forbidden": "Din organisation tillader ikke, at mail gemmes på enheder.",
|
||||
"unsupported": "Denne browser kan ikke gemme mail til brug offline.",
|
||||
"syncing": "Gemmer din mail på denne enhed…",
|
||||
"usage": "{size} på denne enhed",
|
||||
"usage_updated": "{size} på denne enhed, opdateret {time}",
|
||||
"off_confirm_title": "Slå offlinetilstand fra?",
|
||||
"off_confirm_message": "{count, plural, one {# besked i udbakken er ikke sendt.} other {# beskeder i udbakken er ikke sendt.}} Hvis du slår det fra, slettes de sammen med den mail, der er gemt på denne enhed.",
|
||||
"off_confirm": "Slå fra"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Udbakke",
|
||||
"description": "Beskeder, du skrev offline. De sendes, når enheden er online igen.",
|
||||
"empty": "Intet venter på at blive sendt.",
|
||||
"no_subject": "(intet emne)",
|
||||
"to": "Til: {recipients}",
|
||||
"status_waiting": "Venter på forbindelse",
|
||||
"status_sending": "Sendes",
|
||||
"status_failed": "Kunne ikke sendes: {error}",
|
||||
"status_uncertain": "Forbindelsen blev afbrudt under afsendelsen. Se i Sendt, før du sender den igen.",
|
||||
"retry": "Send igen",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Gemt i udbakken. Den sendes, når du er online igen.",
|
||||
"sent": "Sendt fra udbakken: {subject}",
|
||||
"send_failed": "En besked i udbakken kunne ikke sendes: {subject}",
|
||||
"refused_delete": "Endelig sletning kræver forbindelse. Prøv igen, når du er online.",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Denne besked er ikke længere tilgængelig.",
|
||||
"thread_not_found": "Denne samtale er ikke længere tilgængelig.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "Eine neue Version von {appName} ist bereit.",
|
||||
"reload": "Neu laden"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Offline",
|
||||
"description": "E-Mails auf diesem Gerät ohne Verbindung lesen und schreiben.",
|
||||
"keep_label": "E-Mails auf diesem Gerät behalten",
|
||||
"keep_description": "Behält die letzten 30 Tage Ihres Posteingangs und der Ordner, die Sie öffnen, bis zu 250 MB, verschlüsselt in diesem Browser. Offline können Sie E-Mails lesen, markieren und verschieben, und was Sie schreiben, wartet im Postausgang. Beim Abmelden wird alles entfernt.",
|
||||
"warning": "Wer dieses Browserprofil benutzen kann, kann diese E-Mails lesen. Schalten Sie das nur auf Ihrem eigenen Gerät ein und verwenden Sie eine Festplattenverschlüsselung.",
|
||||
"forbidden": "Ihre Organisation erlaubt nicht, E-Mails auf Geräten zu behalten.",
|
||||
"unsupported": "Dieser Browser kann keine E-Mails für die Offline-Nutzung behalten.",
|
||||
"syncing": "Ihre E-Mails werden auf diesem Gerät gespeichert…",
|
||||
"usage": "{size} auf diesem Gerät",
|
||||
"usage_updated": "{size} auf diesem Gerät, aktualisiert {time}",
|
||||
"off_confirm_title": "Offline-Modus ausschalten?",
|
||||
"off_confirm_message": "{count, plural, one {# Nachricht im Postausgang wurde noch nicht gesendet.} other {# Nachrichten im Postausgang wurden noch nicht gesendet.}} Beim Ausschalten werden sie und die auf diesem Gerät behaltenen E-Mails gelöscht.",
|
||||
"off_confirm": "Ausschalten"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Postausgang",
|
||||
"description": "Nachrichten, die Sie offline geschrieben haben. Sie werden gesendet, sobald dieses Gerät wieder online ist.",
|
||||
"empty": "Nichts wartet auf den Versand.",
|
||||
"no_subject": "(kein Betreff)",
|
||||
"to": "An: {recipients}",
|
||||
"status_waiting": "Wartet auf eine Verbindung",
|
||||
"status_sending": "Wird gesendet",
|
||||
"status_failed": "Konnte nicht gesendet werden: {error}",
|
||||
"status_uncertain": "Die Verbindung brach beim Senden ab. Sehen Sie in „Gesendet“ nach, bevor Sie die Nachricht noch einmal senden.",
|
||||
"retry": "Erneut senden",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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.",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Diese Nachricht ist nicht mehr verfügbar.",
|
||||
"thread_not_found": "Diese Unterhaltung ist nicht mehr verfügbar.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "A new version of {appName} is ready.",
|
||||
"reload": "Reload"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Offline",
|
||||
"description": "Read and write mail on this device without a connection.",
|
||||
"keep_label": "Keep mail on this device",
|
||||
"keep_description": "Keeps the last 30 days of your Inbox and of the folders you open, up to 250 MB, encrypted in this browser. Offline you can read, flag and move mail, and what you write waits in the outbox. Signing out removes all of it.",
|
||||
"warning": "Anyone who can use this browser profile can read this mail. Turn this on only on your own device and use disk encryption.",
|
||||
"forbidden": "Your organisation doesn't allow keeping mail on devices.",
|
||||
"unsupported": "This browser can't keep mail for offline use.",
|
||||
"syncing": "Saving your mail on this device…",
|
||||
"usage": "{size} on this device",
|
||||
"usage_updated": "{size} on this device, updated {time}",
|
||||
"off_confirm_title": "Turn off offline mode?",
|
||||
"off_confirm_message": "{count, plural, one {# message in the outbox hasn't been sent.} other {# messages in the outbox haven't been sent.}} Turning this off deletes them and the mail kept on this device.",
|
||||
"off_confirm": "Turn off"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Outbox",
|
||||
"description": "Messages you wrote offline. They're sent when this device is online again.",
|
||||
"empty": "Nothing is waiting to be sent.",
|
||||
"no_subject": "(no subject)",
|
||||
"to": "To: {recipients}",
|
||||
"status_waiting": "Waiting for a connection",
|
||||
"status_sending": "Being sent",
|
||||
"status_failed": "Couldn't be sent: {error}",
|
||||
"status_uncertain": "The connection broke while sending. Check Sent before you send it again.",
|
||||
"retry": "Send again",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Saved in the outbox. It's sent when you're online again.",
|
||||
"sent": "Sent from the outbox: {subject}",
|
||||
"send_failed": "A message in the outbox couldn't be sent: {subject}",
|
||||
"refused_delete": "Deleting for good needs a connection. Try again when you're online.",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "This message is no longer available.",
|
||||
"thread_not_found": "This conversation is no longer available.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "Hay una nueva versión de {appName} lista.",
|
||||
"reload": "Recargar"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Sin conexión",
|
||||
"description": "Lee y escribe correo en este dispositivo sin conexión.",
|
||||
"keep_label": "Guardar el correo en este dispositivo",
|
||||
"keep_description": "Guarda los últimos 30 días de tu bandeja de entrada y de las carpetas que abras, hasta 250 MB, cifrados en este navegador. Sin conexión puedes leer, marcar y mover correo, y lo que escribas espera en la bandeja de salida. Al cerrar sesión se borra todo.",
|
||||
"warning": "Cualquiera que pueda usar este perfil del navegador puede leer este correo. Actívalo solo en tu propio dispositivo y usa cifrado de disco.",
|
||||
"forbidden": "Tu organización no permite guardar correo en los dispositivos.",
|
||||
"unsupported": "Este navegador no puede guardar correo para usarlo sin conexión.",
|
||||
"syncing": "Guardando tu correo en este dispositivo…",
|
||||
"usage": "{size} en este dispositivo",
|
||||
"usage_updated": "{size} en este dispositivo, actualizado {time}",
|
||||
"off_confirm_title": "¿Desactivar el modo sin conexión?",
|
||||
"off_confirm_message": "{count, plural, one {# mensaje de la bandeja de salida no se ha enviado.} other {# mensajes de la bandeja de salida no se han enviado.}} Al desactivarlo se borran, junto con el correo guardado en este dispositivo.",
|
||||
"off_confirm": "Desactivar"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Bandeja de salida",
|
||||
"description": "Mensajes que escribiste sin conexión. Se envían cuando este dispositivo vuelva a tener conexión.",
|
||||
"empty": "No hay nada pendiente de envío.",
|
||||
"no_subject": "(sin asunto)",
|
||||
"to": "Para: {recipients}",
|
||||
"status_waiting": "Esperando conexión",
|
||||
"status_sending": "Enviándose",
|
||||
"status_failed": "No se pudo enviar: {error}",
|
||||
"status_uncertain": "La conexión se cortó durante el envío. Revisa Enviados antes de enviarlo otra vez.",
|
||||
"retry": "Enviar otra vez",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Guardado en la bandeja de salida. Se envía cuando vuelvas a tener conexión.",
|
||||
"sent": "Enviado desde la bandeja de salida: {subject}",
|
||||
"send_failed": "No se pudo enviar un mensaje de la bandeja de salida: {subject}",
|
||||
"refused_delete": "Eliminar definitivamente necesita conexión. Inténtalo de nuevo cuando tengas conexión.",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Este mensaje ya no está disponible.",
|
||||
"thread_not_found": "Esta conversación ya no está disponible.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "{appName} uus versioon on valmis.",
|
||||
"reload": "Laadi uuesti"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Võrguta",
|
||||
"description": "Loe ja kirjuta selles seadmes kirju ilma ühenduseta.",
|
||||
"keep_label": "Hoia kirju selles seadmes",
|
||||
"keep_description": "Hoiab sinu postkasti ja avatud kaustade viimase 30 päeva kirjad, kuni 250 MB, krüptitult selles brauseris. Võrguta saad kirju lugeda, märgistada ja teisaldada ning see, mida kirjutad, ootab väljuvate kirjade kastis. Välja logides eemaldatakse kõik.",
|
||||
"warning": "Igaüks, kes saab seda brauseriprofiili kasutada, saab neid kirju lugeda. Lülita see sisse ainult oma seadmes ja kasuta kettakrüptimist.",
|
||||
"forbidden": "Sinu organisatsioon ei luba kirju seadmetes hoida.",
|
||||
"unsupported": "See brauser ei saa kirju võrguta kasutamiseks hoida.",
|
||||
"syncing": "Kirjade salvestamine sellesse seadmesse…",
|
||||
"usage": "{size} selles seadmes",
|
||||
"usage_updated": "{size} selles seadmes, uuendatud {time}",
|
||||
"off_confirm_title": "Kas lülitada võrguta režiim välja?",
|
||||
"off_confirm_message": "{count, plural, one {# kiri väljuvate kirjade kastis on saatmata.} other {# kirja väljuvate kirjade kastis on saatmata.}} Väljalülitamine kustutab need koos selles seadmes hoitud kirjadega.",
|
||||
"off_confirm": "Lülita välja"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Väljuvad",
|
||||
"description": "Võrguta kirjutatud kirjad. Need saadetakse, kui seade on jälle võrgus.",
|
||||
"empty": "Midagi ei oota saatmist.",
|
||||
"no_subject": "(teemata)",
|
||||
"to": "Saaja: {recipients}",
|
||||
"status_waiting": "Ootab ühendust",
|
||||
"status_sending": "Saatmisel",
|
||||
"status_failed": "Ei saanud saata: {error}",
|
||||
"status_uncertain": "Ühendus katkes saatmise ajal. Vaata enne uuesti saatmist kausta Saadetud.",
|
||||
"retry": "Saada uuesti",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Salvestatud väljuvate kirjade kasti. See saadetakse, kui oled jälle võrgus.",
|
||||
"sent": "Saadetud väljuvate kirjade kastist: {subject}",
|
||||
"send_failed": "Väljuvate kirjade kasti kirja ei saanud saata: {subject}",
|
||||
"refused_delete": "Lõplik kustutamine vajab ühendust. Proovi uuesti, kui oled võrgus.",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "See sõnum ei ole enam saadaval.",
|
||||
"thread_not_found": "See vestlus ei ole enam saadaval.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "نسخهٔ تازهای از {appName} آماده است.",
|
||||
"reload": "بارگیری دوباره"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "آفلاین",
|
||||
"description": "روی این دستگاه بدون اتصال، ایمیل بخوانید و بنویسید.",
|
||||
"keep_label": "نگهداشتن ایمیل روی این دستگاه",
|
||||
"keep_description": "۳۰ روز اخیر صندوق ورودی و پوشههایی را که باز میکنید، تا ۲۵۰ مگابایت، بهصورت رمزگذاریشده در این مرورگر نگه میدارد. در حالت آفلاین میتوانید ایمیلها را بخوانید، پرچم بزنید و جابهجا کنید، و آنچه مینویسید در صندوق خروجی منتظر میماند. با خروج از حساب همه پاک میشود.",
|
||||
"warning": "هر کسی که بتواند از این نمایهٔ مرورگر استفاده کند، میتواند این ایمیلها را بخواند. این را فقط روی دستگاه خودتان روشن کنید و از رمزگذاری دیسک استفاده کنید.",
|
||||
"forbidden": "سازمان شما اجازهٔ نگهداشتن ایمیل روی دستگاهها را نمیدهد.",
|
||||
"unsupported": "این مرورگر نمیتواند ایمیل را برای استفادهٔ آفلاین نگه دارد.",
|
||||
"syncing": "در حال ذخیرهٔ ایمیلها روی این دستگاه…",
|
||||
"usage": "{size} روی این دستگاه",
|
||||
"usage_updated": "{size} روی این دستگاه، بهروزشده {time}",
|
||||
"off_confirm_title": "حالت آفلاین خاموش شود؟",
|
||||
"off_confirm_message": "{count, plural, one {# پیام در صندوق خروجی هنوز ارسال نشده است.} other {# پیام در صندوق خروجی هنوز ارسال نشدهاند.}} با خاموشکردن، آنها و ایمیلهای نگهداشتهشده روی این دستگاه پاک میشوند.",
|
||||
"off_confirm": "خاموش کردن"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "صندوق خروجی",
|
||||
"description": "پیامهایی که آفلاین نوشتید. وقتی این دستگاه دوباره آنلاین شود ارسال میشوند.",
|
||||
"empty": "چیزی منتظر ارسال نیست.",
|
||||
"no_subject": "(بدون موضوع)",
|
||||
"to": "به: {recipients}",
|
||||
"status_waiting": "در انتظار اتصال",
|
||||
"status_sending": "در حال ارسال",
|
||||
"status_failed": "ارسال نشد: {error}",
|
||||
"status_uncertain": "اتصال هنگام ارسال قطع شد. پیش از ارسال دوباره، پوشهٔ ارسالشده را بررسی کنید.",
|
||||
"retry": "ارسال دوباره",
|
||||
"delete": "حذف",
|
||||
"delete_confirm_title": "این پیام حذف شود؟",
|
||||
"delete_confirm_message": "ارسال نشده است و قابل بازگرداندن نیست.",
|
||||
"changes": "{count, plural, one {# تغییر} other {# تغییر}} در خواندهشدن، پرچمها یا پوشهها وقتی دوباره آنلاین شوید ارسال میشود."
|
||||
},
|
||||
"banner": {
|
||||
"offline": "آفلاین هستید. ایمیلهای نگهداشتهشده روی این دستگاه را میبینید. آنچه تغییر میدهید یا مینویسید وقتی دوباره آنلاین شوید ارسال میشود.",
|
||||
"outbox": "{count, plural, one {# پیام} other {# پیام}} در صندوق خروجی"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "در صندوق خروجی ذخیره شد. وقتی دوباره آنلاین شوید ارسال میشود.",
|
||||
"sent": "از صندوق خروجی ارسال شد: {subject}",
|
||||
"send_failed": "یک پیام در صندوق خروجی ارسال نشد: {subject}",
|
||||
"refused_delete": "حذف همیشگی به اتصال نیاز دارد. وقتی آنلاین شدید دوباره امتحان کنید.",
|
||||
"refused_other": "این کار به اتصال نیاز دارد. وقتی آنلاین شدید دوباره امتحان کنید.",
|
||||
"forbidden": "سازمان شما حالت آفلاین را خاموش کرد. ایمیلهای نگهداشتهشده روی این دستگاه پاک شدند.",
|
||||
"back_online": "دوباره آنلاین هستید. برای ورود و ارسال موارد منتظر، صفحه را دوباره بارگیری کنید.",
|
||||
"reload": "بارگیری دوباره"
|
||||
},
|
||||
"signout": {
|
||||
"title": "با پیامهای ارسالنشده خارج میشوید؟",
|
||||
"message": "{count, plural, one {# پیام ارسال نشده است.} other {# پیام ارسال نشدهاند.}} خروج آنها را پاک میکند.",
|
||||
"confirm": "خروج",
|
||||
"lost": "این پیامها ارسال نشدند و هنگام خروج شما پاک شدند: {subjects}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "این پیام دیگر در دسترس نیست.",
|
||||
"thread_not_found": "این گفتگو دیگر در دسترس نیست.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "Une nouvelle version de {appName} est prête.",
|
||||
"reload": "Recharger"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Hors ligne",
|
||||
"description": "Lire et écrire des e-mails sur cet appareil sans connexion.",
|
||||
"keep_label": "Garder les e-mails sur cet appareil",
|
||||
"keep_description": "Garde les 30 derniers jours de votre boîte de réception et des dossiers que vous ouvrez, jusqu'à 250 Mo, chiffrés dans ce navigateur. Hors ligne, vous pouvez lire, signaler et déplacer des e-mails, et ce que vous écrivez attend dans la boîte d'envoi. La déconnexion supprime tout.",
|
||||
"warning": "Toute personne qui peut utiliser ce profil de navigateur peut lire ces e-mails. Activez cette option uniquement sur votre propre appareil et chiffrez son disque.",
|
||||
"forbidden": "Votre organisation ne permet pas de garder les e-mails sur les appareils.",
|
||||
"unsupported": "Ce navigateur ne peut pas garder d'e-mails pour une utilisation hors ligne.",
|
||||
"syncing": "Enregistrement de vos e-mails sur cet appareil…",
|
||||
"usage": "{size} sur cet appareil",
|
||||
"usage_updated": "{size} sur cet appareil, mis à jour {time}",
|
||||
"off_confirm_title": "Désactiver le mode hors ligne ?",
|
||||
"off_confirm_message": "{count, plural, one {# message de la boîte d'envoi n'a pas été envoyé.} other {# messages de la boîte d'envoi n'ont pas été envoyés.}} La désactivation les supprime, ainsi que les e-mails gardés sur cet appareil.",
|
||||
"off_confirm": "Désactiver"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Boîte d'envoi",
|
||||
"description": "Les messages écrits hors ligne. Ils sont envoyés dès que cet appareil est de nouveau en ligne.",
|
||||
"empty": "Rien n'attend d'être envoyé.",
|
||||
"no_subject": "(sans objet)",
|
||||
"to": "À : {recipients}",
|
||||
"status_waiting": "En attente d'une connexion",
|
||||
"status_sending": "En cours d'envoi",
|
||||
"status_failed": "Envoi impossible : {error}",
|
||||
"status_uncertain": "La connexion a été coupée pendant l'envoi. Vérifiez les Envoyés avant de l'envoyer à nouveau.",
|
||||
"retry": "Renvoyer",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Enregistré dans la boîte d'envoi. Il sera envoyé dès que vous serez en ligne.",
|
||||
"sent": "Envoyé depuis la boîte d'envoi : {subject}",
|
||||
"send_failed": "Un message de la boîte d'envoi n'a pas pu être envoyé : {subject}",
|
||||
"refused_delete": "La suppression définitive demande une connexion. Réessayez quand vous serez en ligne.",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Ce message n'est plus disponible.",
|
||||
"thread_not_found": "Cette conversation n'est plus disponible.",
|
||||
|
||||
@@ -5638,6 +5638,59 @@
|
||||
"ready": "גרסה חדשה של {appName} מוכנה.",
|
||||
"reload": "טעינה מחדש"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "לא מקוון",
|
||||
"description": "קריאה וכתיבה של דואר במכשיר הזה בלי חיבור.",
|
||||
"keep_label": "שמירת דואר במכשיר הזה",
|
||||
"keep_description": "שומר את 30 הימים האחרונים של תיבת הדואר הנכנס ושל התיקיות שפתחת, עד 250 MB, מוצפנים בדפדפן הזה. במצב לא מקוון אפשר לקרוא, לסמן ולהעביר דואר, ומה שכותבים ממתין בתיבת הדואר היוצא. התנתקות מוחקת הכול.",
|
||||
"warning": "כל מי שיכול להשתמש בפרופיל הדפדפן הזה יכול לקרוא את הדואר הזה. כדאי להפעיל זאת רק במכשיר שלך ולהשתמש בהצפנת דיסק.",
|
||||
"forbidden": "הארגון שלך לא מאפשר לשמור דואר במכשירים.",
|
||||
"unsupported": "הדפדפן הזה לא יכול לשמור דואר לשימוש לא מקוון.",
|
||||
"syncing": "הדואר שלך נשמר במכשיר הזה…",
|
||||
"usage": "{size} במכשיר הזה",
|
||||
"usage_updated": "{size} במכשיר הזה, עודכן {time}",
|
||||
"off_confirm_title": "לכבות את המצב הלא מקוון?",
|
||||
"off_confirm_message": "{count, plural, one {הודעה אחת בתיבת הדואר היוצא לא נשלחה.} other {# הודעות בתיבת הדואר היוצא לא נשלחו.}} הכיבוי מוחק אותן ואת הדואר השמור במכשיר הזה.",
|
||||
"off_confirm": "כיבוי"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "דואר יוצא",
|
||||
"description": "הודעות שכתבת במצב לא מקוון. הן נשלחות כשהמכשיר מחובר שוב.",
|
||||
"empty": "אין הודעות שממתינות לשליחה.",
|
||||
"no_subject": "(ללא נושא)",
|
||||
"to": "אל: {recipients}",
|
||||
"status_waiting": "ממתינה לחיבור",
|
||||
"status_sending": "בשליחה",
|
||||
"status_failed": "לא ניתן היה לשלוח: {error}",
|
||||
"status_uncertain": "החיבור נותק בזמן השליחה. כדאי לבדוק בתיקייה 'נשלחו' לפני ששולחים שוב.",
|
||||
"retry": "שליחה חוזרת",
|
||||
"delete": "מחיקה",
|
||||
"delete_confirm_title": "למחוק את ההודעה?",
|
||||
"delete_confirm_message": "היא לא נשלחה ואי אפשר לשחזר אותה.",
|
||||
"changes": "{count, plural, one {שינוי אחד} other {# שינויים}} בסטטוס קריאה, בסימונים או בתיקיות יישלחו כשתהיה שוב מחובר."
|
||||
},
|
||||
"banner": {
|
||||
"offline": "אין חיבור. מוצג הדואר השמור במכשיר הזה. מה שמשנים או כותבים יישלח כשיהיה שוב חיבור.",
|
||||
"outbox": "{count, plural, one {הודעה אחת} other {# הודעות}} בתיבת הדואר היוצא"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "נשמרה בתיבת הדואר היוצא. היא תישלח כשיהיה שוב חיבור.",
|
||||
"sent": "נשלחה מתיבת הדואר היוצא: {subject}",
|
||||
"send_failed": "לא ניתן היה לשלוח הודעה מתיבת הדואר היוצא: {subject}",
|
||||
"refused_delete": "מחיקה לצמיתות דורשת חיבור. אפשר לנסות שוב כשיהיה חיבור.",
|
||||
"refused_other": "הפעולה הזו דורשת חיבור. אפשר לנסות שוב כשיהיה חיבור.",
|
||||
"forbidden": "הארגון שלך כיבה את המצב הלא מקוון. הדואר שנשמר במכשיר הזה הוסר.",
|
||||
"back_online": "יש שוב חיבור. יש לטעון מחדש כדי להתחבר ולשלוח את מה שממתין.",
|
||||
"reload": "טעינה מחדש"
|
||||
},
|
||||
"signout": {
|
||||
"title": "להתנתק עם הודעות שלא נשלחו?",
|
||||
"message": "{count, plural, one {הודעה אחת לא נשלחה.} other {# הודעות לא נשלחו.}} ההתנתקות מוחקת אותן.",
|
||||
"confirm": "התנתקות",
|
||||
"lost": "ההודעות האלה לא נשלחו ונמחקו בזמן ההתנתקות: {subjects}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "הודעה זו אינה זמינה עוד.",
|
||||
"thread_not_found": "שיחה זו אינה זמינה עוד.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "Elkészült a(z) {appName} új verziója.",
|
||||
"reload": "Újratöltés"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Offline",
|
||||
"description": "Levelek olvasása és írása ezen az eszközön kapcsolat nélkül.",
|
||||
"keep_label": "Levelek megtartása ezen az eszközön",
|
||||
"keep_description": "Megtartja a beérkezett levelek és a megnyitott mappák utolsó 30 napját, legfeljebb 250 MB-ot, titkosítva ebben a böngészőben. Offline olvashat, megjelölhet és áthelyezhet leveleket, a megírt levelek pedig a postázandók között várnak. Kijelentkezéskor minden törlődik.",
|
||||
"warning": "Bárki, aki használhatja ezt a böngészőprofilt, elolvashatja ezeket a leveleket. Csak a saját eszközén kapcsolja be, és használjon lemeztitkosítást.",
|
||||
"forbidden": "A szervezete nem engedi, hogy leveleket tároljon az eszközökön.",
|
||||
"unsupported": "Ez a böngésző nem tud leveleket tárolni offline használatra.",
|
||||
"syncing": "A levelek mentése erre az eszközre…",
|
||||
"usage": "{size} ezen az eszközön",
|
||||
"usage_updated": "{size} ezen az eszközön, frissítve: {time}",
|
||||
"off_confirm_title": "Kikapcsolja az offline módot?",
|
||||
"off_confirm_message": "{count, plural, one {# levél a postázandók között még nincs elküldve.} other {# levél a postázandók között még nincs elküldve.}} Kikapcsoláskor ezek és az eszközön tárolt levelek törlődnek.",
|
||||
"off_confirm": "Kikapcsolás"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Postázandó",
|
||||
"description": "Az offline írt levelek. Akkor mennek el, amikor az eszköz újra online lesz.",
|
||||
"empty": "Nincs elküldésre váró levél.",
|
||||
"no_subject": "(nincs tárgy)",
|
||||
"to": "Címzett: {recipients}",
|
||||
"status_waiting": "Kapcsolatra vár",
|
||||
"status_sending": "Küldés folyamatban",
|
||||
"status_failed": "Nem sikerült elküldeni: {error}",
|
||||
"status_uncertain": "A kapcsolat megszakadt küldés közben. Nézze meg az Elküldött mappát, mielőtt újra elküldi.",
|
||||
"retry": "Újraküldés",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Mentve a postázandók közé. Akkor megy el, amikor újra online lesz.",
|
||||
"sent": "Elküldve a postázandók közül: {subject}",
|
||||
"send_failed": "Egy postázandó levelet nem sikerült elküldeni: {subject}",
|
||||
"refused_delete": "A végleges törléshez kapcsolat kell. Próbálja újra, amikor online lesz.",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Ez az üzenet már nem érhető el.",
|
||||
"thread_not_found": "Ez a beszélgetés már nem érhető el.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "È pronta una nuova versione di {appName}.",
|
||||
"reload": "Ricarica"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Offline",
|
||||
"description": "Leggi e scrivi la posta su questo dispositivo senza connessione.",
|
||||
"keep_label": "Conserva la posta su questo dispositivo",
|
||||
"keep_description": "Conserva gli ultimi 30 giorni della posta in arrivo e delle cartelle che apri, fino a 250 MB, cifrati in questo browser. Offline puoi leggere, contrassegnare e spostare la posta, e ciò che scrivi aspetta nella posta in uscita. Uscendo dall'account viene rimosso tutto.",
|
||||
"warning": "Chiunque possa usare questo profilo del browser può leggere questa posta. Attivalo solo sul tuo dispositivo e usa la cifratura del disco.",
|
||||
"forbidden": "La tua organizzazione non consente di conservare la posta sui dispositivi.",
|
||||
"unsupported": "Questo browser non può conservare la posta per l'uso offline.",
|
||||
"syncing": "Salvataggio della posta su questo dispositivo…",
|
||||
"usage": "{size} su questo dispositivo",
|
||||
"usage_updated": "{size} su questo dispositivo, aggiornato {time}",
|
||||
"off_confirm_title": "Disattivare la modalità offline?",
|
||||
"off_confirm_message": "{count, plural, one {# messaggio nella posta in uscita non è stato inviato.} other {# messaggi nella posta in uscita non sono stati inviati.}} Disattivandola vengono eliminati, insieme alla posta conservata su questo dispositivo.",
|
||||
"off_confirm": "Disattiva"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Posta in uscita",
|
||||
"description": "Messaggi scritti offline. Vengono inviati quando questo dispositivo torna online.",
|
||||
"empty": "Nulla in attesa di invio.",
|
||||
"no_subject": "(nessun oggetto)",
|
||||
"to": "A: {recipients}",
|
||||
"status_waiting": "In attesa di connessione",
|
||||
"status_sending": "Invio in corso",
|
||||
"status_failed": "Impossibile inviare: {error}",
|
||||
"status_uncertain": "La connessione si è interrotta durante l'invio. Controlla Inviati prima di inviarlo di nuovo.",
|
||||
"retry": "Invia di nuovo",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Salvato nella posta in uscita. Verrà inviato quando sarai di nuovo online.",
|
||||
"sent": "Inviato dalla posta in uscita: {subject}",
|
||||
"send_failed": "Non è stato possibile inviare un messaggio della posta in uscita: {subject}",
|
||||
"refused_delete": "L'eliminazione definitiva richiede una connessione. Riprova quando sei online.",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Questo messaggio non è più disponibile.",
|
||||
"thread_not_found": "Questa conversazione non è più disponibile.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "{appName} の新しいバージョンを利用できます。",
|
||||
"reload": "再読み込み"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "オフライン",
|
||||
"description": "接続がなくても、このデバイスでメールを読み書きできます。",
|
||||
"keep_label": "このデバイスにメールを保存",
|
||||
"keep_description": "受信トレイと開いたフォルダーの直近 30 日分を最大 250 MB まで、このブラウザーに暗号化して保存します。オフラインでもメールを読む、フラグを付ける、移動することができ、書いたメールは送信トレイで待機します。サインアウトするとすべて削除されます。",
|
||||
"warning": "このブラウザーのプロファイルを使える人は誰でもこのメールを読めます。自分のデバイスでのみオンにし、ディスク暗号化を使ってください。",
|
||||
"forbidden": "組織の設定により、デバイスにメールを保存することはできません。",
|
||||
"unsupported": "このブラウザーはオフライン用にメールを保存できません。",
|
||||
"syncing": "このデバイスにメールを保存しています…",
|
||||
"usage": "このデバイスで {size}",
|
||||
"usage_updated": "このデバイスで {size}、{time}に更新",
|
||||
"off_confirm_title": "オフラインモードをオフにしますか?",
|
||||
"off_confirm_message": "{count, plural, other {送信トレイに未送信のメッセージが # 件あります。}}オフにすると、それらとこのデバイスに保存したメールが削除されます。",
|
||||
"off_confirm": "オフにする"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "送信トレイ",
|
||||
"description": "オフラインで書いたメッセージです。このデバイスが再びオンラインになると送信されます。",
|
||||
"empty": "送信待ちのものはありません。",
|
||||
"no_subject": "(件名なし)",
|
||||
"to": "宛先: {recipients}",
|
||||
"status_waiting": "接続を待っています",
|
||||
"status_sending": "送信中",
|
||||
"status_failed": "送信できませんでした: {error}",
|
||||
"status_uncertain": "送信中に接続が切れました。もう一度送信する前に送信済みを確認してください。",
|
||||
"retry": "もう一度送信",
|
||||
"delete": "削除",
|
||||
"delete_confirm_title": "このメッセージを削除しますか?",
|
||||
"delete_confirm_message": "送信されておらず、元に戻せません。",
|
||||
"changes": "{count, plural, other {既読、フラグ、フォルダーの変更 # 件}}は、再びオンラインになると送信されます。"
|
||||
},
|
||||
"banner": {
|
||||
"offline": "オフラインです。このデバイスに保存したメールを表示しています。変更や作成した内容は、再びオンラインになると送信されます。",
|
||||
"outbox": "{count, plural, other {送信トレイに # 件}}"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "送信トレイに保存しました。再びオンラインになると送信されます。",
|
||||
"sent": "送信トレイから送信しました: {subject}",
|
||||
"send_failed": "送信トレイのメッセージを送信できませんでした: {subject}",
|
||||
"refused_delete": "完全に削除するには接続が必要です。オンラインになってからもう一度お試しください。",
|
||||
"refused_other": "この操作には接続が必要です。オンラインになってからもう一度お試しください。",
|
||||
"forbidden": "組織によってオフラインモードがオフになりました。このデバイスに保存したメールは削除されました。",
|
||||
"back_online": "再びオンラインになりました。再読み込みしてサインインし、待機中のものを送信してください。",
|
||||
"reload": "再読み込み"
|
||||
},
|
||||
"signout": {
|
||||
"title": "未送信のメッセージがある状態でサインアウトしますか?",
|
||||
"message": "{count, plural, other {# 件のメッセージが未送信です。}}サインアウトすると削除されます。",
|
||||
"confirm": "サインアウト",
|
||||
"lost": "次のメッセージは送信されず、サインアウト時に削除されました: {subjects}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "このメッセージは利用できなくなりました。",
|
||||
"thread_not_found": "この会話は利用できなくなりました。",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "{appName}의 새 버전이 준비되었습니다.",
|
||||
"reload": "새로고침"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "오프라인",
|
||||
"description": "연결 없이도 이 기기에서 메일을 읽고 쓸 수 있습니다.",
|
||||
"keep_label": "이 기기에 메일 보관",
|
||||
"keep_description": "받은편지함과 연 폴더의 최근 30일 메일을 최대 250MB까지 이 브라우저에 암호화해 보관합니다. 오프라인에서도 메일을 읽고, 플래그를 지정하고, 이동할 수 있으며, 작성한 메일은 보낼편지함에서 대기합니다. 로그아웃하면 모두 삭제됩니다.",
|
||||
"warning": "이 브라우저 프로필을 사용할 수 있는 사람은 누구나 이 메일을 읽을 수 있습니다. 본인 기기에서만 켜고 디스크 암호화를 사용하세요.",
|
||||
"forbidden": "조직에서 기기에 메일을 보관하는 것을 허용하지 않습니다.",
|
||||
"unsupported": "이 브라우저는 오프라인용으로 메일을 보관할 수 없습니다.",
|
||||
"syncing": "이 기기에 메일을 저장하는 중…",
|
||||
"usage": "이 기기에서 {size}",
|
||||
"usage_updated": "이 기기에서 {size}, {time} 업데이트됨",
|
||||
"off_confirm_title": "오프라인 모드를 끄시겠습니까?",
|
||||
"off_confirm_message": "{count, plural, other {보낼편지함에 보내지 않은 메시지가 #개 있습니다.}} 끄면 이 메시지와 이 기기에 보관한 메일이 삭제됩니다.",
|
||||
"off_confirm": "끄기"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "보낼편지함",
|
||||
"description": "오프라인에서 작성한 메시지입니다. 이 기기가 다시 온라인이 되면 전송됩니다.",
|
||||
"empty": "전송을 기다리는 메시지가 없습니다.",
|
||||
"no_subject": "(제목 없음)",
|
||||
"to": "받는 사람: {recipients}",
|
||||
"status_waiting": "연결 대기 중",
|
||||
"status_sending": "전송 중",
|
||||
"status_failed": "보낼 수 없음: {error}",
|
||||
"status_uncertain": "전송 중에 연결이 끊어졌습니다. 다시 보내기 전에 보낸편지함을 확인하세요.",
|
||||
"retry": "다시 보내기",
|
||||
"delete": "삭제",
|
||||
"delete_confirm_title": "이 메시지를 삭제하시겠습니까?",
|
||||
"delete_confirm_message": "전송되지 않았으며 되돌릴 수 없습니다.",
|
||||
"changes": "읽음, 플래그, 폴더 {count, plural, other {변경 #건}}은 다시 온라인이 되면 전송됩니다."
|
||||
},
|
||||
"banner": {
|
||||
"offline": "오프라인 상태입니다. 이 기기에 보관한 메일이 표시됩니다. 변경하거나 작성한 내용은 다시 온라인이 되면 전송됩니다.",
|
||||
"outbox": "보낼편지함에 {count, plural, other {#개}}"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "보낼편지함에 저장했습니다. 다시 온라인이 되면 전송됩니다.",
|
||||
"sent": "보낼편지함에서 보냄: {subject}",
|
||||
"send_failed": "보낼편지함의 메시지를 보낼 수 없습니다: {subject}",
|
||||
"refused_delete": "영구 삭제하려면 연결이 필요합니다. 온라인이 되면 다시 시도하세요.",
|
||||
"refused_other": "이 작업에는 연결이 필요합니다. 온라인이 되면 다시 시도하세요.",
|
||||
"forbidden": "조직에서 오프라인 모드를 껐습니다. 이 기기에 보관한 메일이 삭제되었습니다.",
|
||||
"back_online": "다시 온라인입니다. 새로고침해서 로그인하고 대기 중인 항목을 보내세요.",
|
||||
"reload": "새로고침"
|
||||
},
|
||||
"signout": {
|
||||
"title": "보내지 않은 메시지가 있는데 로그아웃하시겠습니까?",
|
||||
"message": "{count, plural, other {메시지 #개가 전송되지 않았습니다.}} 로그아웃하면 삭제됩니다.",
|
||||
"confirm": "로그아웃",
|
||||
"lost": "다음 메시지는 전송되지 않았으며 로그아웃 시 삭제되었습니다: {subjects}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.",
|
||||
"thread_not_found": "이 대화를 더 이상 사용할 수 없습니다.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "Ir gatava jauna {appName} versija.",
|
||||
"reload": "Pārlādēt"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Bezsaistē",
|
||||
"description": "Lasiet un rakstiet e-pastu šajā ierīcē bez savienojuma.",
|
||||
"keep_label": "Glabāt e-pastu šajā ierīcē",
|
||||
"keep_description": "Glabā iesūtnes un jūsu atvērto mapju pēdējās 30 dienas, līdz 250 MB, šifrētas šajā pārlūkā. Bezsaistē varat lasīt, atzīmēt un pārvietot vēstules, un tas, ko rakstāt, gaida izsūtnē. Izrakstoties viss tiek noņemts.",
|
||||
"warning": "Ikviens, kas var izmantot šo pārlūka profilu, var lasīt šīs vēstules. Ieslēdziet to tikai savā ierīcē un izmantojiet diska šifrēšanu.",
|
||||
"forbidden": "Jūsu organizācija neļauj glabāt e-pastu ierīcēs.",
|
||||
"unsupported": "Šis pārlūks nevar glabāt e-pastu lietošanai bezsaistē.",
|
||||
"syncing": "Jūsu e-pasts tiek saglabāts šajā ierīcē…",
|
||||
"usage": "{size} šajā ierīcē",
|
||||
"usage_updated": "{size} šajā ierīcē, atjaunināts {time}",
|
||||
"off_confirm_title": "Izslēgt bezsaistes režīmu?",
|
||||
"off_confirm_message": "{count, plural, zero {# vēstules izsūtnē nav nosūtītas.} one {# vēstule izsūtnē nav nosūtīta.} other {# vēstules izsūtnē nav nosūtītas.}} Izslēdzot tās tiek izdzēstas kopā ar šajā ierīcē glabāto e-pastu.",
|
||||
"off_confirm": "Izslēgt"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Izsūtne",
|
||||
"description": "Vēstules, ko rakstījāt bezsaistē. Tās tiek nosūtītas, kad ierīce atkal ir tiešsaistē.",
|
||||
"empty": "Nekas negaida nosūtīšanu.",
|
||||
"no_subject": "(bez temata)",
|
||||
"to": "Kam: {recipients}",
|
||||
"status_waiting": "Gaida savienojumu",
|
||||
"status_sending": "Tiek sūtīta",
|
||||
"status_failed": "Neizdevās nosūtīt: {error}",
|
||||
"status_uncertain": "Savienojums pārtrūka sūtīšanas laikā. Pirms sūtāt vēlreiz, pārbaudiet mapi Nosūtītās.",
|
||||
"retry": "Sūtīt vēlreiz",
|
||||
"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ē."
|
||||
},
|
||||
"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ē"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Saglabāta izsūtnē. Tā tiks nosūtīta, kad atkal būsiet tiešsaistē.",
|
||||
"sent": "Nosūtīta no izsūtnes: {subject}",
|
||||
"send_failed": "Neizdevās nosūtīt vēstuli no izsūtnes: {subject}",
|
||||
"refused_delete": "Neatgriezeniskai dzēšanai vajag savienojumu. Mēģiniet vēlreiz, kad būsiet tiešsaistē.",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Šī ziņa vairs nav pieejama.",
|
||||
"thread_not_found": "Šī sarakste vairs nav pieejama.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "{appName}-ийн шинэ хувилбар бэлэн боллоо.",
|
||||
"reload": "Дахин ачаалах"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Офлайн",
|
||||
"description": "Энэ төхөөрөмж дээр холболтгүйгээр имэйл уншиж, бичээрэй.",
|
||||
"keep_label": "Имэйлийг энэ төхөөрөмж дээр хадгалах",
|
||||
"keep_description": "Ирсэн имэйл болон таны нээсэн хавтаснуудын сүүлийн 30 хоногийг 250 МБ хүртэл, энэ хөтөч дээр шифрлэн хадгална. Офлайн үед имэйл уншиж, тэмдэглэж, зөөж болох ба бичсэн зүйл тань гарах хайрцагт хүлээнэ. Гарахад бүгд устна.",
|
||||
"warning": "Энэ хөтчийн профайлыг ашиглаж чадах хэн ч эдгээр имэйлийг уншиж чадна. Үүнийг зөвхөн өөрийн төхөөрөмж дээр асааж, дискний шифрлэлт ашиглаарай.",
|
||||
"forbidden": "Таны байгууллага имэйлийг төхөөрөмж дээр хадгалахыг зөвшөөрдөггүй.",
|
||||
"unsupported": "Энэ хөтөч офлайн ашиглахад зориулж имэйл хадгалж чадахгүй.",
|
||||
"syncing": "Таны имэйлийг энэ төхөөрөмж дээр хадгалж байна…",
|
||||
"usage": "Энэ төхөөрөмж дээр {size}",
|
||||
"usage_updated": "Энэ төхөөрөмж дээр {size}, {time} шинэчилсэн",
|
||||
"off_confirm_title": "Офлайн горимыг унтраах уу?",
|
||||
"off_confirm_message": "{count, plural, one {Гарах хайрцагт # мессеж илгээгдээгүй байна.} other {Гарах хайрцагт # мессеж илгээгдээгүй байна.}} Унтраавал тэдгээр болон энэ төхөөрөмж дээр хадгалсан имэйл устна.",
|
||||
"off_confirm": "Унтраах"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Гарах хайрцаг",
|
||||
"description": "Офлайн үед бичсэн мессежүүд. Төхөөрөмж дахин онлайн болоход илгээгдэнэ.",
|
||||
"empty": "Илгээхийг хүлээж буй зүйл алга.",
|
||||
"no_subject": "(гарчиггүй)",
|
||||
"to": "Хэнд: {recipients}",
|
||||
"status_waiting": "Холболт хүлээж байна",
|
||||
"status_sending": "Илгээж байна",
|
||||
"status_failed": "Илгээж чадсангүй: {error}",
|
||||
"status_uncertain": "Илгээх үед холболт тасарсан. Дахин илгээхээсээ өмнө Илгээсэн хавтсыг шалгаарай.",
|
||||
"retry": "Дахин илгээх",
|
||||
"delete": "Устгах",
|
||||
"delete_confirm_title": "Энэ мессежийг устгах уу?",
|
||||
"delete_confirm_message": "Илгээгдээгүй бөгөөд сэргээх боломжгүй.",
|
||||
"changes": "Уншсан, тэмдэглэгээ эсвэл хавтасны {count, plural, one {# өөрчлөлт} other {# өөрчлөлт}} таныг дахин онлайн болоход илгээгдэнэ."
|
||||
},
|
||||
"banner": {
|
||||
"offline": "Та офлайн байна. Энэ төхөөрөмж дээр хадгалсан имэйлийг харж байна. Өөрчилсөн эсвэл бичсэн зүйл тань дахин онлайн болоход илгээгдэнэ.",
|
||||
"outbox": "Гарах хайрцагт {count, plural, one {# мессеж} other {# мессеж}}"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Гарах хайрцагт хадгаллаа. Таныг дахин онлайн болоход илгээгдэнэ.",
|
||||
"sent": "Гарах хайрцгаас илгээлээ: {subject}",
|
||||
"send_failed": "Гарах хайрцаг дахь мессежийг илгээж чадсангүй: {subject}",
|
||||
"refused_delete": "Бүрмөсөн устгахад холболт хэрэгтэй. Онлайн болсон үедээ дахин оролдоно уу.",
|
||||
"refused_other": "Үүнд холболт хэрэгтэй. Онлайн болсон үедээ дахин оролдоно уу.",
|
||||
"forbidden": "Таны байгууллага офлайн горимыг унтраасан. Энэ төхөөрөмж дээр хадгалсан имэйлийг устгалаа.",
|
||||
"back_online": "Та дахин онлайн боллоо. Нэвтэрч, хүлээгдэж буйг илгээхийн тулд дахин ачаална уу.",
|
||||
"reload": "Дахин ачаалах"
|
||||
},
|
||||
"signout": {
|
||||
"title": "Илгээгдээгүй мессежтэй гарах уу?",
|
||||
"message": "{count, plural, one {# мессеж илгээгдээгүй байна.} other {# мессеж илгээгдээгүй байна.}} Гарвал тэдгээр устна.",
|
||||
"confirm": "Гарах",
|
||||
"lost": "Эдгээр мессеж илгээгдээгүй бөгөөд таныг гарах үед устгагдсан: {subjects}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Энэ зурвас дахин ашиглах боломжгүй болсон.",
|
||||
"thread_not_found": "Энэ харилцан яриа дахин ашиглах боломжгүй болсон.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "En ny versjon av {appName} er klar.",
|
||||
"reload": "Last inn på nytt"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Frakoblet",
|
||||
"description": "Les og skriv e-post på denne enheten uten tilkobling.",
|
||||
"keep_label": "Behold e-post på denne enheten",
|
||||
"keep_description": "Beholder de siste 30 dagene av innboksen og av mappene du åpner, opptil 250 MB, kryptert i denne nettleseren. Frakoblet kan du lese, flagge og flytte e-post, og det du skriver, venter i utboksen. Når du logger av, fjernes alt.",
|
||||
"warning": "Alle som kan bruke denne nettleserprofilen, kan lese denne e-posten. Slå det bare på på din egen enhet, og bruk diskkryptering.",
|
||||
"forbidden": "Organisasjonen din tillater ikke at e-post beholdes på enheter.",
|
||||
"unsupported": "Denne nettleseren kan ikke beholde e-post for bruk uten tilkobling.",
|
||||
"syncing": "Lagrer e-posten din på denne enheten…",
|
||||
"usage": "{size} på denne enheten",
|
||||
"usage_updated": "{size} på denne enheten, oppdatert {time}",
|
||||
"off_confirm_title": "Slå av frakoblet modus?",
|
||||
"off_confirm_message": "{count, plural, one {# melding i utboksen er ikke sendt.} other {# meldinger i utboksen er ikke sendt.}} Hvis du slår det av, slettes de sammen med e-posten som er lagret på denne enheten.",
|
||||
"off_confirm": "Slå av"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Utboks",
|
||||
"description": "Meldinger du skrev uten tilkobling. De sendes når enheten er tilkoblet igjen.",
|
||||
"empty": "Ingenting venter på å bli sendt.",
|
||||
"no_subject": "(uten emne)",
|
||||
"to": "Til: {recipients}",
|
||||
"status_waiting": "Venter på tilkobling",
|
||||
"status_sending": "Sendes",
|
||||
"status_failed": "Kunne ikke sendes: {error}",
|
||||
"status_uncertain": "Tilkoblingen ble brutt under sendingen. Sjekk Sendt før du sender den igjen.",
|
||||
"retry": "Send igjen",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Lagret i utboksen. Den sendes når du er tilkoblet igjen.",
|
||||
"sent": "Sendt fra utboksen: {subject}",
|
||||
"send_failed": "En melding i utboksen kunne ikke sendes: {subject}",
|
||||
"refused_delete": "Permanent sletting krever tilkobling. Prøv igjen når du er tilkoblet.",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Denne meldingen er ikke lenger tilgjengelig.",
|
||||
"thread_not_found": "Denne samtalen er ikke lenger tilgjengelig.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "Er is een nieuwe versie van {appName} klaar.",
|
||||
"reload": "Opnieuw laden"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"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.",
|
||||
"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.",
|
||||
"syncing": "Je e-mail wordt op dit apparaat opgeslagen…",
|
||||
"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": "Uitzetten"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Outbox",
|
||||
"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)",
|
||||
"to": "Aan: {recipients}",
|
||||
"status_waiting": "Wacht op verbinding",
|
||||
"status_sending": "Wordt verzonden",
|
||||
"status_failed": "Kon niet worden verzonden: {error}",
|
||||
"status_uncertain": "De verbinding viel weg tijdens het verzenden. Kijk in Verzonden voordat je het opnieuw verzendt.",
|
||||
"retry": "Opnieuw verzenden",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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}",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Dit bericht is niet meer beschikbaar.",
|
||||
"thread_not_found": "Dit gesprek is niet meer beschikbaar.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "Nowa wersja {appName} jest gotowa.",
|
||||
"reload": "Odśwież"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Offline",
|
||||
"description": "Czytaj i pisz pocztę na tym urządzeniu bez połączenia.",
|
||||
"keep_label": "Przechowuj pocztę na tym urządzeniu",
|
||||
"keep_description": "Przechowuje ostatnie 30 dni skrzynki odbiorczej i otwieranych folderów, do 250 MB, zaszyfrowane w tej przeglądarce. Offline możesz czytać, oznaczać i przenosić wiadomości, a to, co napiszesz, czeka w skrzynce nadawczej. Wylogowanie usuwa wszystko.",
|
||||
"warning": "Każdy, kto może używać tego profilu przeglądarki, może przeczytać tę pocztę. Włącz to tylko na własnym urządzeniu i używaj szyfrowania dysku.",
|
||||
"forbidden": "Twoja organizacja nie pozwala przechowywać poczty na urządzeniach.",
|
||||
"unsupported": "Ta przeglądarka nie może przechowywać poczty do pracy offline.",
|
||||
"syncing": "Zapisywanie poczty na tym urządzeniu…",
|
||||
"usage": "{size} na tym urządzeniu",
|
||||
"usage_updated": "{size} na tym urządzeniu, zaktualizowano {time}",
|
||||
"off_confirm_title": "Wyłączyć tryb offline?",
|
||||
"off_confirm_message": "{count, plural, one {# wiadomość w skrzynce nadawczej nie została wysłana.} few {# wiadomości w skrzynce nadawczej nie zostały wysłane.} many {# wiadomości w skrzynce nadawczej nie zostało wysłanych.} other {# wiadomości w skrzynce nadawczej nie zostało wysłanych.}} Wyłączenie usuwa je razem z pocztą przechowywaną na tym urządzeniu.",
|
||||
"off_confirm": "Wyłącz"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Skrzynka nadawcza",
|
||||
"description": "Wiadomości napisane offline. Zostaną wysłane, gdy urządzenie znów będzie online.",
|
||||
"empty": "Nic nie czeka na wysłanie.",
|
||||
"no_subject": "(bez tematu)",
|
||||
"to": "Do: {recipients}",
|
||||
"status_waiting": "Czeka na połączenie",
|
||||
"status_sending": "Wysyłanie",
|
||||
"status_failed": "Nie udało się wysłać: {error}",
|
||||
"status_uncertain": "Połączenie zerwało się podczas wysyłania. Zajrzyj do Wysłanych, zanim wyślesz ją ponownie.",
|
||||
"retry": "Wyślij ponownie",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Zapisano w skrzynce nadawczej. Zostanie wysłana, gdy znów będziesz online.",
|
||||
"sent": "Wysłano ze skrzynki nadawczej: {subject}",
|
||||
"send_failed": "Nie udało się wysłać wiadomości ze skrzynki nadawczej: {subject}",
|
||||
"refused_delete": "Trwałe usuwanie wymaga połączenia. Spróbuj ponownie, gdy będziesz online.",
|
||||
"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ż"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Ta wiadomość nie jest już dostępna.",
|
||||
"thread_not_found": "Ta rozmowa nie jest już dostępna.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "Uma nova versão do {appName} está pronta.",
|
||||
"reload": "Recarregar"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Offline",
|
||||
"description": "Leia e escreva e-mails neste dispositivo sem ligação.",
|
||||
"keep_label": "Manter o e-mail neste dispositivo",
|
||||
"keep_description": "Mantém os últimos 30 dias da caixa de entrada e das pastas que abrir, até 250 MB, cifrados neste navegador. Offline pode ler, sinalizar e mover e-mails, e o que escrever espera na caixa de saída. Terminar a sessão remove tudo.",
|
||||
"warning": "Qualquer pessoa que possa usar este perfil do navegador pode ler estes e-mails. Ative isto apenas no seu próprio dispositivo e use cifragem de disco.",
|
||||
"forbidden": "A sua organização não permite manter e-mails nos dispositivos.",
|
||||
"unsupported": "Este navegador não consegue manter e-mails para uso offline.",
|
||||
"syncing": "A guardar o seu e-mail neste dispositivo…",
|
||||
"usage": "{size} neste dispositivo",
|
||||
"usage_updated": "{size} neste dispositivo, atualizado {time}",
|
||||
"off_confirm_title": "Desativar o modo offline?",
|
||||
"off_confirm_message": "{count, plural, one {# mensagem na caixa de saída não foi enviada.} other {# mensagens na caixa de saída não foram enviadas.}} Desativar elimina-as, juntamente com o e-mail mantido neste dispositivo.",
|
||||
"off_confirm": "Desativar"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Caixa de saída",
|
||||
"description": "Mensagens que escreveu offline. São enviadas quando este dispositivo voltar a estar online.",
|
||||
"empty": "Nada à espera de ser enviado.",
|
||||
"no_subject": "(sem assunto)",
|
||||
"to": "Para: {recipients}",
|
||||
"status_waiting": "À espera de ligação",
|
||||
"status_sending": "A ser enviada",
|
||||
"status_failed": "Não foi possível enviar: {error}",
|
||||
"status_uncertain": "A ligação caiu durante o envio. Veja os Enviados antes de a enviar outra vez.",
|
||||
"retry": "Enviar outra vez",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Guardada na caixa de saída. É enviada quando voltar a estar online.",
|
||||
"sent": "Enviada da caixa de saída: {subject}",
|
||||
"send_failed": "Não foi possível enviar uma mensagem da caixa de saída: {subject}",
|
||||
"refused_delete": "Eliminar definitivamente requer ligação. Tente novamente quando estiver online.",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Esta mensagem não está mais disponível.",
|
||||
"thread_not_found": "Esta conversa não está mais disponível.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "O versiune nouă de {appName} este gata.",
|
||||
"reload": "Reîncarcă"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"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.",
|
||||
"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.",
|
||||
"syncing": "Se salvează e-mailurile pe acest dispozitiv…",
|
||||
"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": "Dezactivează"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Outbox",
|
||||
"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)",
|
||||
"to": "Către: {recipients}",
|
||||
"status_waiting": "Așteaptă o conexiune",
|
||||
"status_sending": "Se trimite",
|
||||
"status_failed": "Nu a putut fi trimis: {error}",
|
||||
"status_uncertain": "Conexiunea s-a întrerupt în timpul trimiterii. Verificați Trimise înainte să îl trimiteți din nou.",
|
||||
"retry": "Trimite din nou",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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}",
|
||||
"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ă"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Acest mesaj nu mai este disponibil.",
|
||||
"thread_not_found": "Această conversație nu mai este disponibilă.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "Готова новая версия {appName}.",
|
||||
"reload": "Перезагрузить"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Офлайн",
|
||||
"description": "Читайте и пишите письма на этом устройстве без подключения.",
|
||||
"keep_label": "Хранить почту на этом устройстве",
|
||||
"keep_description": "Хранит последние 30 дней входящих и открытых вами папок, до 250 МБ, в зашифрованном виде в этом браузере. Без сети можно читать, помечать и перемещать письма, а написанное ждёт в исходящих. При выходе всё удаляется.",
|
||||
"warning": "Любой, кто может пользоваться этим профилем браузера, может прочитать эту почту. Включайте это только на своём устройстве и используйте шифрование диска.",
|
||||
"forbidden": "Ваша организация не разрешает хранить почту на устройствах.",
|
||||
"unsupported": "Этот браузер не может хранить почту для работы без сети.",
|
||||
"syncing": "Почта сохраняется на этом устройстве…",
|
||||
"usage": "{size} на этом устройстве",
|
||||
"usage_updated": "{size} на этом устройстве, обновлено {time}",
|
||||
"off_confirm_title": "Выключить офлайн-режим?",
|
||||
"off_confirm_message": "{count, plural, one {# письмо в исходящих не отправлено.} few {# письма в исходящих не отправлены.} many {# писем в исходящих не отправлено.} other {# письма в исходящих не отправлены.}} При выключении они удалятся вместе с почтой, сохранённой на этом устройстве.",
|
||||
"off_confirm": "Выключить"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Исходящие",
|
||||
"description": "Письма, написанные без сети. Они отправятся, когда устройство снова будет в сети.",
|
||||
"empty": "Ничего не ждёт отправки.",
|
||||
"no_subject": "(без темы)",
|
||||
"to": "Кому: {recipients}",
|
||||
"status_waiting": "Ждёт подключения",
|
||||
"status_sending": "Отправляется",
|
||||
"status_failed": "Не удалось отправить: {error}",
|
||||
"status_uncertain": "Соединение оборвалось во время отправки. Прежде чем отправить снова, проверьте «Отправленные».",
|
||||
"retry": "Отправить снова",
|
||||
"delete": "Удалить",
|
||||
"delete_confirm_title": "Удалить это письмо?",
|
||||
"delete_confirm_message": "Оно не отправлено, и его нельзя вернуть.",
|
||||
"changes": "{count, plural, one {# изменение} few {# изменения} many {# изменений} other {# изменения}} прочтения, флажков или папок отправятся, когда вы снова будете в сети."
|
||||
},
|
||||
"banner": {
|
||||
"offline": "Вы не в сети. Показана почта, сохранённая на этом устройстве. То, что вы измените или напишете, отправится, когда вы снова будете в сети.",
|
||||
"outbox": "{count, plural, one {# письмо} few {# письма} many {# писем} other {# письма}} в исходящих"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Сохранено в исходящих. Отправится, когда вы снова будете в сети.",
|
||||
"sent": "Отправлено из исходящих: {subject}",
|
||||
"send_failed": "Не удалось отправить письмо из исходящих: {subject}",
|
||||
"refused_delete": "Для окончательного удаления нужно подключение. Повторите, когда будете в сети.",
|
||||
"refused_other": "Для этого нужно подключение. Повторите, когда будете в сети.",
|
||||
"forbidden": "Ваша организация выключила офлайн-режим. Почта, сохранённая на этом устройстве, удалена.",
|
||||
"back_online": "Вы снова в сети. Перезагрузите страницу, чтобы войти и отправить ожидающее.",
|
||||
"reload": "Перезагрузить"
|
||||
},
|
||||
"signout": {
|
||||
"title": "Выйти с неотправленными письмами?",
|
||||
"message": "{count, plural, one {# письмо не отправлено.} few {# письма не отправлены.} many {# писем не отправлено.} other {# письма не отправлены.}} При выходе они удалятся.",
|
||||
"confirm": "Выйти",
|
||||
"lost": "Эти письма не были отправлены и удалились при выходе: {subjects}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Это письмо больше недоступно.",
|
||||
"thread_not_found": "Эта переписка больше недоступна.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "Je pripravená nová verzia aplikácie {appName}.",
|
||||
"reload": "Načítať znova"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Offline",
|
||||
"description": "Čítajte a píšte poštu na tomto zariadení bez pripojenia.",
|
||||
"keep_label": "Uchovávať poštu na tomto zariadení",
|
||||
"keep_description": "Uchováva posledných 30 dní doručenej pošty a priečinkov, ktoré otvoríte, až 250 MB, zašifrované v tomto prehliadači. Offline môžete poštu čítať, označovať a presúvať a to, čo napíšete, čaká na odoslanie. Odhlásenie všetko odstráni.",
|
||||
"warning": "Ktokoľvek, kto môže používať tento profil prehliadača, si môže túto poštu prečítať. Zapnite to len na svojom zariadení a používajte šifrovanie disku.",
|
||||
"forbidden": "Vaša organizácia nepovoľuje uchovávať poštu na zariadeniach.",
|
||||
"unsupported": "Tento prehliadač nemôže uchovávať poštu na prácu offline.",
|
||||
"syncing": "Ukladanie pošty do tohto zariadenia…",
|
||||
"usage": "{size} na tomto zariadení",
|
||||
"usage_updated": "{size} na tomto zariadení, aktualizované {time}",
|
||||
"off_confirm_title": "Vypnúť režim offline?",
|
||||
"off_confirm_message": "{count, plural, one {# správa na odoslanie nebola odoslaná.} few {# správy na odoslanie neboli odoslané.} many {# správy na odoslanie nebolo odoslaných.} other {# správ na odoslanie nebolo odoslaných.}} Vypnutím sa vymažú spolu s poštou uchovanou na tomto zariadení.",
|
||||
"off_confirm": "Vypnúť"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Na odoslanie",
|
||||
"description": "Správy napísané offline. Odošlú sa, keď bude zariadenie znova online.",
|
||||
"empty": "Nič nečaká na odoslanie.",
|
||||
"no_subject": "(bez predmetu)",
|
||||
"to": "Komu: {recipients}",
|
||||
"status_waiting": "Čaká na pripojenie",
|
||||
"status_sending": "Odosiela sa",
|
||||
"status_failed": "Nedá sa odoslať: {error}",
|
||||
"status_uncertain": "Pripojenie sa pri odosielaní prerušilo. Kým ju odošlete znova, pozrite sa do Odoslaných.",
|
||||
"retry": "Odoslať znova",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Uložené na odoslanie. Odošle sa, keď budete znova online.",
|
||||
"sent": "Odoslané z fronty na odoslanie: {subject}",
|
||||
"send_failed": "Správu na odoslanie sa nepodarilo odoslať: {subject}",
|
||||
"refused_delete": "Trvalé vymazanie vyžaduje pripojenie. Skúste to znova, keď budete online.",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Táto správa už nie je dostupná.",
|
||||
"thread_not_found": "Táto konverzácia už nie je dostupná.",
|
||||
|
||||
@@ -2021,6 +2021,59 @@
|
||||
"ready": "{appName} เวอร์ชันใหม่พร้อมแล้ว",
|
||||
"reload": "โหลดใหม่"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "ออฟไลน์",
|
||||
"description": "อ่านและเขียนอีเมลบนอุปกรณ์นี้ได้โดยไม่ต้องเชื่อมต่อ",
|
||||
"keep_label": "เก็บอีเมลไว้บนอุปกรณ์นี้",
|
||||
"keep_description": "เก็บอีเมล 30 วันล่าสุดของกล่องจดหมายเข้าและโฟลเดอร์ที่คุณเปิด สูงสุด 250 MB โดยเข้ารหัสไว้ในเบราว์เซอร์นี้ เมื่อออฟไลน์คุณอ่าน ติดธง และย้ายอีเมลได้ และสิ่งที่คุณเขียนจะรออยู่ในกล่องขาออก เมื่อออกจากระบบทุกอย่างจะถูกลบ",
|
||||
"warning": "ใครก็ตามที่ใช้โปรไฟล์เบราว์เซอร์นี้ได้จะอ่านอีเมลเหล่านี้ได้ เปิดใช้เฉพาะบนอุปกรณ์ของคุณเอง และใช้การเข้ารหัสดิสก์",
|
||||
"forbidden": "องค์กรของคุณไม่อนุญาตให้เก็บอีเมลไว้บนอุปกรณ์",
|
||||
"unsupported": "เบราว์เซอร์นี้เก็บอีเมลไว้ใช้แบบออฟไลน์ไม่ได้",
|
||||
"syncing": "กำลังบันทึกอีเมลของคุณลงในอุปกรณ์นี้…",
|
||||
"usage": "{size} บนอุปกรณ์นี้",
|
||||
"usage_updated": "{size} บนอุปกรณ์นี้ อัปเดต {time}",
|
||||
"off_confirm_title": "ปิดโหมดออฟไลน์ไหม",
|
||||
"off_confirm_message": "{count, plural, other {มีข้อความในกล่องขาออก # รายการที่ยังไม่ได้ส่ง}} การปิดจะลบข้อความเหล่านั้นและอีเมลที่เก็บไว้บนอุปกรณ์นี้",
|
||||
"off_confirm": "ปิด"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "กล่องขาออก",
|
||||
"description": "ข้อความที่คุณเขียนขณะออฟไลน์ จะถูกส่งเมื่ออุปกรณ์นี้กลับมาออนไลน์",
|
||||
"empty": "ไม่มีอะไรรอส่ง",
|
||||
"no_subject": "(ไม่มีหัวเรื่อง)",
|
||||
"to": "ถึง: {recipients}",
|
||||
"status_waiting": "กำลังรอการเชื่อมต่อ",
|
||||
"status_sending": "กำลังส่ง",
|
||||
"status_failed": "ส่งไม่ได้: {error}",
|
||||
"status_uncertain": "การเชื่อมต่อขาดระหว่างส่ง ตรวจดูในรายการที่ส่งแล้วก่อนส่งอีกครั้ง",
|
||||
"retry": "ส่งอีกครั้ง",
|
||||
"delete": "ลบ",
|
||||
"delete_confirm_title": "ลบข้อความนี้ไหม",
|
||||
"delete_confirm_message": "ข้อความนี้ยังไม่ได้ส่งและกู้คืนไม่ได้",
|
||||
"changes": "การเปลี่ยนแปลง {count, plural, other {# รายการ}} ของสถานะอ่าน ธง หรือโฟลเดอร์จะถูกส่งเมื่อคุณกลับมาออนไลน์"
|
||||
},
|
||||
"banner": {
|
||||
"offline": "คุณออฟไลน์อยู่ คุณกำลังดูอีเมลที่เก็บไว้บนอุปกรณ์นี้ สิ่งที่คุณเปลี่ยนหรือเขียนจะถูกส่งเมื่อกลับมาออนไลน์",
|
||||
"outbox": "{count, plural, other {# ข้อความ}}ในกล่องขาออก"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "บันทึกในกล่องขาออกแล้ว จะถูกส่งเมื่อคุณกลับมาออนไลน์",
|
||||
"sent": "ส่งจากกล่องขาออกแล้ว: {subject}",
|
||||
"send_failed": "ส่งข้อความจากกล่องขาออกไม่ได้: {subject}",
|
||||
"refused_delete": "การลบถาวรต้องใช้การเชื่อมต่อ ลองอีกครั้งเมื่อออนไลน์",
|
||||
"refused_other": "การทำงานนี้ต้องใช้การเชื่อมต่อ ลองอีกครั้งเมื่อออนไลน์",
|
||||
"forbidden": "องค์กรของคุณปิดโหมดออฟไลน์แล้ว อีเมลที่เก็บไว้บนอุปกรณ์นี้ถูกลบแล้ว",
|
||||
"back_online": "คุณกลับมาออนไลน์แล้ว โหลดใหม่เพื่อเข้าสู่ระบบและส่งสิ่งที่รออยู่",
|
||||
"reload": "โหลดใหม่"
|
||||
},
|
||||
"signout": {
|
||||
"title": "ออกจากระบบทั้งที่มีข้อความยังไม่ได้ส่งไหม",
|
||||
"message": "{count, plural, other {มีข้อความ # รายการที่ยังไม่ได้ส่ง}} การออกจากระบบจะลบข้อความเหล่านั้น",
|
||||
"confirm": "ออกจากระบบ",
|
||||
"lost": "ข้อความเหล่านี้ไม่ได้ถูกส่งและถูกลบเมื่อคุณออกจากระบบ: {subjects}"
|
||||
}
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "กำลังโหลดหน้าเว็บ...",
|
||||
"not_available_title": "หน้านี้ไม่พร้อมใช้งาน",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "{appName} uygulamasının yeni sürümü hazır.",
|
||||
"reload": "Yeniden yükle"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Çevrimdışı",
|
||||
"description": "Bu cihazda bağlantı olmadan e-posta okuyun ve yazın.",
|
||||
"keep_label": "E-postayı bu cihazda tut",
|
||||
"keep_description": "Gelen kutunuzun ve açtığınız klasörlerin son 30 gününü, en fazla 250 MB, bu tarayıcıda şifreli olarak tutar. Çevrimdışıyken e-postaları okuyabilir, işaretleyebilir ve taşıyabilirsiniz; yazdıklarınız giden kutusunda bekler. Oturumu kapatınca hepsi silinir.",
|
||||
"warning": "Bu tarayıcı profilini kullanabilen herkes bu e-postaları okuyabilir. Bunu yalnızca kendi cihazınızda açın ve disk şifrelemesi kullanın.",
|
||||
"forbidden": "Kuruluşunuz e-postaların cihazlarda tutulmasına izin vermiyor.",
|
||||
"unsupported": "Bu tarayıcı çevrimdışı kullanım için e-posta tutamıyor.",
|
||||
"syncing": "E-postalarınız bu cihaza kaydediliyor…",
|
||||
"usage": "Bu cihazda {size}",
|
||||
"usage_updated": "Bu cihazda {size}, {time} güncellendi",
|
||||
"off_confirm_title": "Çevrimdışı mod kapatılsın mı?",
|
||||
"off_confirm_message": "{count, plural, one {Giden kutusundaki # ileti gönderilmedi.} other {Giden kutusundaki # ileti gönderilmedi.}} Kapatmak bunları ve bu cihazda tutulan e-postaları siler.",
|
||||
"off_confirm": "Kapat"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Giden kutusu",
|
||||
"description": "Çevrimdışıyken yazdığınız iletiler. Bu cihaz yeniden çevrimiçi olunca gönderilir.",
|
||||
"empty": "Gönderilmeyi bekleyen bir şey yok.",
|
||||
"no_subject": "(konu yok)",
|
||||
"to": "Kime: {recipients}",
|
||||
"status_waiting": "Bağlantı bekleniyor",
|
||||
"status_sending": "Gönderiliyor",
|
||||
"status_failed": "Gönderilemedi: {error}",
|
||||
"status_uncertain": "Gönderirken bağlantı koptu. Yeniden göndermeden önce Gönderilenler'e bakın.",
|
||||
"retry": "Yeniden gönder",
|
||||
"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."
|
||||
},
|
||||
"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}}"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Giden kutusuna kaydedildi. Yeniden çevrimiçi olduğunuzda gönderilir.",
|
||||
"sent": "Giden kutusundan gönderildi: {subject}",
|
||||
"send_failed": "Giden kutusundaki bir ileti gönderilemedi: {subject}",
|
||||
"refused_delete": "Kalıcı silme için bağlantı gerekir. Çevrimiçi olunca yeniden deneyin.",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Bu ileti artık kullanılamıyor.",
|
||||
"thread_not_found": "Bu sohbet artık kullanılamıyor.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "Нова версія {appName} готова.",
|
||||
"reload": "Перезавантажити"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Офлайн",
|
||||
"description": "Читайте й пишіть листи на цьому пристрої без з'єднання.",
|
||||
"keep_label": "Зберігати пошту на цьому пристрої",
|
||||
"keep_description": "Зберігає останні 30 днів вхідних і відкритих вами тек, до 250 МБ, у зашифрованому вигляді в цьому браузері. Без мережі можна читати, позначати й переміщати листи, а написане чекає у вихідних. Під час виходу все видаляється.",
|
||||
"warning": "Будь-хто, хто може користуватися цим профілем браузера, може прочитати цю пошту. Вмикайте це лише на власному пристрої та використовуйте шифрування диска.",
|
||||
"forbidden": "Ваша організація не дозволяє зберігати пошту на пристроях.",
|
||||
"unsupported": "Цей браузер не може зберігати пошту для роботи без мережі.",
|
||||
"syncing": "Пошта зберігається на цьому пристрої…",
|
||||
"usage": "{size} на цьому пристрої",
|
||||
"usage_updated": "{size} на цьому пристрої, оновлено {time}",
|
||||
"off_confirm_title": "Вимкнути офлайн-режим?",
|
||||
"off_confirm_message": "{count, plural, one {# лист у вихідних не надіслано.} few {# листи у вихідних не надіслано.} many {# листів у вихідних не надіслано.} other {# листа у вихідних не надіслано.}} Під час вимкнення їх буде видалено разом із поштою, збереженою на цьому пристрої.",
|
||||
"off_confirm": "Вимкнути"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Вихідні",
|
||||
"description": "Листи, написані без мережі. Їх буде надіслано, коли пристрій знову буде в мережі.",
|
||||
"empty": "Ніщо не чекає на надсилання.",
|
||||
"no_subject": "(без теми)",
|
||||
"to": "Кому: {recipients}",
|
||||
"status_waiting": "Чекає на з'єднання",
|
||||
"status_sending": "Надсилається",
|
||||
"status_failed": "Не вдалося надіслати: {error}",
|
||||
"status_uncertain": "З'єднання обірвалося під час надсилання. Перш ніж надіслати знову, перевірте «Надіслані».",
|
||||
"retry": "Надіслати знову",
|
||||
"delete": "Видалити",
|
||||
"delete_confirm_title": "Видалити цей лист?",
|
||||
"delete_confirm_message": "Його не надіслано, і його не можна повернути.",
|
||||
"changes": "{count, plural, one {# зміну} few {# зміни} many {# змін} other {# зміни}} прочитання, позначок або тек буде надіслано, коли ви знову будете в мережі."
|
||||
},
|
||||
"banner": {
|
||||
"offline": "Ви не в мережі. Показано пошту, збережену на цьому пристрої. Те, що ви зміните або напишете, буде надіслано, коли ви знову будете в мережі.",
|
||||
"outbox": "{count, plural, one {# лист} few {# листи} many {# листів} other {# листа}} у вихідних"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "Збережено у вихідних. Його буде надіслано, коли ви знову будете в мережі.",
|
||||
"sent": "Надіслано з вихідних: {subject}",
|
||||
"send_failed": "Не вдалося надіслати лист із вихідних: {subject}",
|
||||
"refused_delete": "Для остаточного видалення потрібне з'єднання. Спробуйте знову, коли будете в мережі.",
|
||||
"refused_other": "Для цього потрібне з'єднання. Спробуйте знову, коли будете в мережі.",
|
||||
"forbidden": "Ваша організація вимкнула офлайн-режим. Пошту, збережену на цьому пристрої, видалено.",
|
||||
"back_online": "Ви знову в мережі. Перезавантажте сторінку, щоб увійти й надіслати те, що чекає.",
|
||||
"reload": "Перезавантажити"
|
||||
},
|
||||
"signout": {
|
||||
"title": "Вийти з ненадісланими листами?",
|
||||
"message": "{count, plural, one {# лист не надіслано.} few {# листи не надіслано.} many {# листів не надіслано.} other {# листа не надіслано.}} Під час виходу їх буде видалено.",
|
||||
"confirm": "Вийти",
|
||||
"lost": "Ці листи не було надіслано, і їх видалено під час виходу: {subjects}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Цей лист більше недоступний.",
|
||||
"thread_not_found": "Ця розмова більше недоступна.",
|
||||
|
||||
@@ -5649,6 +5649,59 @@
|
||||
"ready": "Đã có phiên bản mới của {appName}.",
|
||||
"reload": "Tải lại"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "Ngoại tuyến",
|
||||
"description": "Đọc và viết thư trên thiết bị này khi không có kết nối.",
|
||||
"keep_label": "Giữ thư trên thiết bị này",
|
||||
"keep_description": "Giữ 30 ngày gần nhất của Hộp thư đến và các thư mục bạn mở, tối đa 250 MB, được mã hóa trong trình duyệt này. Khi ngoại tuyến bạn có thể đọc, gắn cờ và di chuyển thư, còn thư bạn viết sẽ chờ trong Hộp thư đi. Khi đăng xuất, tất cả sẽ bị xóa.",
|
||||
"warning": "Bất kỳ ai dùng được hồ sơ trình duyệt này đều đọc được các thư này. Chỉ bật trên thiết bị của riêng bạn và dùng mã hóa ổ đĩa.",
|
||||
"forbidden": "Tổ chức của bạn không cho phép giữ thư trên thiết bị.",
|
||||
"unsupported": "Trình duyệt này không thể giữ thư để dùng ngoại tuyến.",
|
||||
"syncing": "Đang lưu thư của bạn vào thiết bị này…",
|
||||
"usage": "{size} trên thiết bị này",
|
||||
"usage_updated": "{size} trên thiết bị này, cập nhật {time}",
|
||||
"off_confirm_title": "Tắt chế độ ngoại tuyến?",
|
||||
"off_confirm_message": "{count, plural, other {Có # thư trong Hộp thư đi chưa được gửi.}} Tắt sẽ xóa chúng cùng với thư được giữ trên thiết bị này.",
|
||||
"off_confirm": "Tắt"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "Hộp thư đi",
|
||||
"description": "Thư bạn viết khi ngoại tuyến. Chúng sẽ được gửi khi thiết bị này trực tuyến trở lại.",
|
||||
"empty": "Không có gì đang chờ gửi.",
|
||||
"no_subject": "(không có tiêu đề)",
|
||||
"to": "Đến: {recipients}",
|
||||
"status_waiting": "Đang chờ kết nối",
|
||||
"status_sending": "Đang gửi",
|
||||
"status_failed": "Không gửi được: {error}",
|
||||
"status_uncertain": "Kết nối bị ngắt khi đang gửi. Hãy kiểm tra Thư đã gửi trước khi gửi lại.",
|
||||
"retry": "Gửi lại",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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.",
|
||||
"sent": "Đã gửi từ Hộp thư đi: {subject}",
|
||||
"send_failed": "Không gửi được một thư trong Hộp thư đi: {subject}",
|
||||
"refused_delete": "Xóa vĩnh viễn cần có kết nối. Hãy thử lại khi bạn trực tuyến.",
|
||||
"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"
|
||||
},
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "Thư này không còn nữa.",
|
||||
"thread_not_found": "Cuộc trò chuyện này không còn tồn tại nữa.",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "{appName} 新版本已準備就緒。",
|
||||
"reload": "重新載入"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "離線",
|
||||
"description": "在此裝置上不需連線即可讀寫郵件。",
|
||||
"keep_label": "在此裝置上保留郵件",
|
||||
"keep_description": "在此瀏覽器中加密保留收件匣和你開啟過的資料夾最近 30 天的郵件,最多 250 MB。離線時可以閱讀、標記和移動郵件,寫好的郵件會在寄件匣中等待。登出時會全部刪除。",
|
||||
"warning": "任何能使用此瀏覽器設定檔的人都能讀取這些郵件。請只在自己的裝置上開啟,並使用磁碟加密。",
|
||||
"forbidden": "你的組織不允許在裝置上保留郵件。",
|
||||
"unsupported": "此瀏覽器無法保留郵件供離線使用。",
|
||||
"syncing": "正在將郵件儲存到此裝置…",
|
||||
"usage": "此裝置上 {size}",
|
||||
"usage_updated": "此裝置上 {size},更新於 {time}",
|
||||
"off_confirm_title": "關閉離線模式?",
|
||||
"off_confirm_message": "{count, plural, other {寄件匣中有 # 封郵件尚未寄出。}}關閉後,這些郵件和此裝置上保留的郵件都會被刪除。",
|
||||
"off_confirm": "關閉"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "寄件匣",
|
||||
"description": "你離線時寫的郵件。此裝置恢復連線後會寄出。",
|
||||
"empty": "沒有等待寄出的郵件。",
|
||||
"no_subject": "(無主旨)",
|
||||
"to": "收件者:{recipients}",
|
||||
"status_waiting": "等待連線",
|
||||
"status_sending": "正在寄出",
|
||||
"status_failed": "無法寄出:{error}",
|
||||
"status_uncertain": "寄出時連線中斷。再次寄出前請先查看「寄件備份」。",
|
||||
"retry": "再次寄出",
|
||||
"delete": "刪除",
|
||||
"delete_confirm_title": "刪除這封郵件?",
|
||||
"delete_confirm_message": "它尚未寄出,刪除後無法復原。",
|
||||
"changes": "{count, plural, other {# 項}}已讀、標記或資料夾的變更將在恢復連線後送出。"
|
||||
},
|
||||
"banner": {
|
||||
"offline": "你已離線。顯示的是此裝置上保留的郵件。你所做的變更或寫的郵件會在恢復連線後送出。",
|
||||
"outbox": "寄件匣中有 {count, plural, other {# 封}}"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "已儲存到寄件匣。恢復連線後會寄出。",
|
||||
"sent": "已從寄件匣寄出:{subject}",
|
||||
"send_failed": "寄件匣中的一封郵件無法寄出:{subject}",
|
||||
"refused_delete": "永久刪除需要網路連線。請在連線後重試。",
|
||||
"refused_other": "此操作需要網路連線。請在連線後重試。",
|
||||
"forbidden": "你的組織關閉了離線模式。此裝置上保留的郵件已被刪除。",
|
||||
"back_online": "已恢復連線。請重新載入以登入並寄出等待中的內容。",
|
||||
"reload": "重新載入"
|
||||
},
|
||||
"signout": {
|
||||
"title": "有未寄出的郵件,仍要登出?",
|
||||
"message": "{count, plural, other {# 封郵件尚未寄出。}}登出會刪除它們。",
|
||||
"confirm": "登出",
|
||||
"lost": "這些郵件未寄出,並在你登出時被刪除:{subjects}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "已無法使用此郵件。",
|
||||
"thread_not_found": "已無法使用此對話串。",
|
||||
|
||||
@@ -5637,6 +5637,59 @@
|
||||
"ready": "{appName} 新版本已就绪。",
|
||||
"reload": "重新加载"
|
||||
},
|
||||
"offline": {
|
||||
"settings": {
|
||||
"title": "离线",
|
||||
"description": "在此设备上无需连接即可读写邮件。",
|
||||
"keep_label": "在此设备上保留邮件",
|
||||
"keep_description": "在此浏览器中加密保留收件箱和你打开过的文件夹最近 30 天的邮件,最多 250 MB。离线时可以阅读、标记和移动邮件,写好的邮件会在发件箱中等待。退出登录时会全部删除。",
|
||||
"warning": "任何能使用此浏览器配置文件的人都能读取这些邮件。请只在自己的设备上开启,并使用磁盘加密。",
|
||||
"forbidden": "你的组织不允许在设备上保留邮件。",
|
||||
"unsupported": "此浏览器无法保留邮件供离线使用。",
|
||||
"syncing": "正在将邮件保存到此设备…",
|
||||
"usage": "此设备上 {size}",
|
||||
"usage_updated": "此设备上 {size},更新于 {time}",
|
||||
"off_confirm_title": "关闭离线模式?",
|
||||
"off_confirm_message": "{count, plural, other {发件箱中有 # 封邮件尚未发送。}}关闭后,这些邮件和此设备上保留的邮件都会被删除。",
|
||||
"off_confirm": "关闭"
|
||||
},
|
||||
"outbox": {
|
||||
"title": "发件箱",
|
||||
"description": "你离线时写的邮件。此设备恢复联网后会发送。",
|
||||
"empty": "没有等待发送的邮件。",
|
||||
"no_subject": "(无主题)",
|
||||
"to": "收件人:{recipients}",
|
||||
"status_waiting": "等待连接",
|
||||
"status_sending": "正在发送",
|
||||
"status_failed": "无法发送:{error}",
|
||||
"status_uncertain": "发送时连接中断。再次发送前请先查看“已发送”。",
|
||||
"retry": "再次发送",
|
||||
"delete": "删除",
|
||||
"delete_confirm_title": "删除这封邮件?",
|
||||
"delete_confirm_message": "它尚未发送,删除后无法恢复。",
|
||||
"changes": "{count, plural, other {# 项}}已读、标记或文件夹的更改将在恢复联网后发送。"
|
||||
},
|
||||
"banner": {
|
||||
"offline": "你已离线。显示的是此设备上保留的邮件。你所做的更改或写的邮件会在恢复联网后发送。",
|
||||
"outbox": "发件箱中有 {count, plural, other {# 封}}"
|
||||
},
|
||||
"toast": {
|
||||
"queued": "已保存到发件箱。恢复联网后会发送。",
|
||||
"sent": "已从发件箱发送:{subject}",
|
||||
"send_failed": "发件箱中的一封邮件无法发送:{subject}",
|
||||
"refused_delete": "永久删除需要网络连接。请在联网后重试。",
|
||||
"refused_other": "此操作需要网络连接。请在联网后重试。",
|
||||
"forbidden": "你的组织关闭了离线模式。此设备上保留的邮件已被删除。",
|
||||
"back_online": "已恢复联网。请重新加载以登录并发送等待中的内容。",
|
||||
"reload": "重新加载"
|
||||
},
|
||||
"signout": {
|
||||
"title": "有未发送的邮件,仍要退出登录?",
|
||||
"message": "{count, plural, other {# 封邮件尚未发送。}}退出登录会删除它们。",
|
||||
"confirm": "退出登录",
|
||||
"lost": "这些邮件未发送,并在你退出登录时被删除:{subjects}"
|
||||
}
|
||||
},
|
||||
"deep_link": {
|
||||
"message_not_found": "此邮件已不可用。",
|
||||
"thread_not_found": "此会话已不可用。",
|
||||
|
||||
+201
-3
@@ -41,14 +41,197 @@ const SHELL_CACHE = SHELL_CACHE_PREFIX + BUILD_ID;
|
||||
const SW_STATE_CACHE = "bulwark-sw-state-v1";
|
||||
const CLIENT_BUILDS_KEY = "/__bulwark/client-builds";
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
const OFFLINE_SHELL_KEY = "/__bulwark/offline-shell";
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
// With offline mode on, a tab may load a chunk of its build at any time,
|
||||
// from this worker's caches. A new build's worker waits until those tabs
|
||||
// are gone or one asks it to take over (its "Reload"); without offline
|
||||
// mode nothing is cached and it takes over at once, as it always did.
|
||||
event.waitUntil(readOfflineShell().then((on) => {
|
||||
if (!on) return self.skipWaiting();
|
||||
return self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((open) => {
|
||||
if (open.length === 0) return self.skipWaiting();
|
||||
return undefined;
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(Promise.all([self.clients.claim(), deleteStaleShellCaches()]));
|
||||
});
|
||||
|
||||
// ---- Offline mode (#523): the app shell ----
|
||||
//
|
||||
// Only while a tab turned offline mode on (Settings > Offline). Then:
|
||||
// - pages of the app: network first; the answer is kept in this build's
|
||||
// cache, and used when the network fails;
|
||||
// - /_next/static/: content-hashed files, from any build's cache first
|
||||
// (an open tab of an older build still finds its chunks), else the
|
||||
// network, kept in this build's cache;
|
||||
// - /api/config: network first, the last answer when offline.
|
||||
// Everything else, and everything while offline mode is off, goes to the
|
||||
// network untouched.
|
||||
|
||||
// null until read from the state cache; true or false after.
|
||||
let offlineShellOn = null;
|
||||
|
||||
async function readOfflineShell() {
|
||||
if (offlineShellOn !== null) return offlineShellOn;
|
||||
try {
|
||||
const cache = await caches.open(SW_STATE_CACHE);
|
||||
const response = await cache.match(OFFLINE_SHELL_KEY);
|
||||
const data = response ? await response.json() : null;
|
||||
offlineShellOn = !!(data && data.on === true);
|
||||
} catch (_) {
|
||||
offlineShellOn = false;
|
||||
}
|
||||
return offlineShellOn;
|
||||
}
|
||||
|
||||
async function writeOfflineShell(on) {
|
||||
offlineShellOn = on;
|
||||
try {
|
||||
const cache = await caches.open(SW_STATE_CACHE);
|
||||
await cache.put(OFFLINE_SHELL_KEY, new Response(JSON.stringify({ on }), { headers: { "Content-Type": "application/json" } }));
|
||||
} catch (_) {
|
||||
// No Cache API: nothing is cached either.
|
||||
}
|
||||
}
|
||||
|
||||
// Read at start, so the fetch handler mostly knows synchronously.
|
||||
if (typeof caches !== "undefined" && caches && typeof caches.open === "function") {
|
||||
readOfflineShell();
|
||||
}
|
||||
|
||||
const APP_SECTIONS = new Set(["", "mail", "settings", "calendar", "contacts", "files", "pro", "apps"]);
|
||||
|
||||
/** The page path inside the app ("/mail/inbox"), or null for anything that isn't a page of the app. */
|
||||
function appPagePath(url) {
|
||||
if (url.origin !== self.location.origin) return null;
|
||||
let path = url.pathname;
|
||||
if (BASE_PATH) {
|
||||
if (path !== BASE_PATH && !path.startsWith(BASE_PATH + "/")) return null;
|
||||
path = path.slice(BASE_PATH.length) || "/";
|
||||
}
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
// An optional locale segment (/de/mail, /zh-TW/settings).
|
||||
if (parts.length > 0 && /^[a-z]{2}(-[A-Za-z]{2,4})?$/.test(parts[0]) && !APP_SECTIONS.has(parts[0])) parts.shift();
|
||||
return APP_SECTIONS.has(parts[0] || "") ? "/" + parts.join("/") : null;
|
||||
}
|
||||
|
||||
function staticPath(url) {
|
||||
if (url.origin !== self.location.origin) return false;
|
||||
return url.pathname.startsWith(BASE_PATH + "/_next/static/");
|
||||
}
|
||||
|
||||
/** Where a page of the app is kept: its address without the query. */
|
||||
function documentKey(url) {
|
||||
return url.origin + url.pathname.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** A page the cache has for this address: the page itself, its section, the app's start page. */
|
||||
async function cachedDocument(url) {
|
||||
const path = appPagePath(url) || "/";
|
||||
const section = "/" + (path.split("/").filter(Boolean)[0] || "");
|
||||
const candidates = [documentKey(url), self.location.origin + BASE_PATH + (section === "/" ? "" : section), self.location.origin + BASE_PATH];
|
||||
const own = await caches.open(SHELL_CACHE);
|
||||
for (const key of candidates) {
|
||||
const hit = await own.match(key);
|
||||
if (hit) return hit;
|
||||
}
|
||||
for (const key of candidates) {
|
||||
const hit = await caches.match(key);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function respondWithDocument(request) {
|
||||
const url = new URL(request.url);
|
||||
try {
|
||||
const response = await fetch(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(() => {});
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
const hit = await cachedDocument(url);
|
||||
if (hit) return hit;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function respondWithStatic(request) {
|
||||
const hit = await caches.match(request.url);
|
||||
if (hit) return hit;
|
||||
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(() => {});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function respondWithConfig(request) {
|
||||
const key = self.location.origin + BASE_PATH + "/api/config";
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const copy = response.clone();
|
||||
caches.open(SHELL_CACHE).then((cache) => cache.put(key, copy)).catch(() => {});
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
const hit = await caches.match(key);
|
||||
if (hit) return hit;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function offlineRoute(request) {
|
||||
if (request.method !== "GET") return null;
|
||||
const url = new URL(request.url);
|
||||
if (request.mode === "navigate" && appPagePath(url) !== null) return respondWithDocument;
|
||||
if (staticPath(url)) return respondWithStatic;
|
||||
if (url.origin === self.location.origin && url.pathname === BASE_PATH + "/api/config") return respondWithConfig;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Keeps the files a tab names (this build's shell) in this build's cache. */
|
||||
async function precacheShell(urls) {
|
||||
if (!Array.isArray(urls) || urls.length === 0) return;
|
||||
const cache = await caches.open(SHELL_CACHE);
|
||||
const wanted = urls.filter((u) => typeof u === "string");
|
||||
for (let i = 0; i < wanted.length; i += 8) {
|
||||
await Promise.all(wanted.slice(i, i + 8).map(async (u) => {
|
||||
try {
|
||||
const url = new URL(u, self.location.origin);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
const key = appPagePath(url) !== null && !staticPath(url) ? documentKey(url) : url.href;
|
||||
if (await cache.match(key)) return;
|
||||
const response = await fetch(url.href, { credentials: "same-origin" });
|
||||
if (response.ok && response.type === "basic" && !response.redirected) await cache.put(key, response);
|
||||
} catch (_) {
|
||||
// Fetched again when the page asks next time.
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function setOfflineShell(on, urls) {
|
||||
await writeOfflineShell(on);
|
||||
if (on) {
|
||||
await precacheShell(urls);
|
||||
return;
|
||||
}
|
||||
// Off: nothing of the shell stays.
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.filter((key) => key.startsWith(SHELL_CACHE_PREFIX)).map((key) => caches.delete(key)));
|
||||
}
|
||||
|
||||
async function readClientBuilds() {
|
||||
try {
|
||||
const cache = await caches.open(SW_STATE_CACHE);
|
||||
@@ -104,7 +287,18 @@ async function deleteStaleShellCaches() {
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", () => {});
|
||||
self.addEventListener("fetch", (event) => {
|
||||
// Offline mode off (known): the network, untouched, as without a worker.
|
||||
if (offlineShellOn === false) return;
|
||||
const route = offlineRoute(event.request);
|
||||
if (!route) return;
|
||||
if (offlineShellOn === true) {
|
||||
event.respondWith(route(event.request));
|
||||
return;
|
||||
}
|
||||
// Not read yet (the worker just started): decide once it is.
|
||||
event.respondWith(readOfflineShell().then((on) => (on ? route(event.request) : fetch(event.request))));
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
event.waitUntil(handlePush(event));
|
||||
@@ -125,6 +319,10 @@ self.addEventListener("message", (event) => {
|
||||
event.waitUntil(recordClientBuild(event.source && event.source.id, data.buildId).then(deleteStaleShellCaches));
|
||||
return;
|
||||
}
|
||||
if (data.type === "offline-shell") {
|
||||
event.waitUntil(setOfflineShell(data.on === true, data.urls));
|
||||
return;
|
||||
}
|
||||
if (data.type === "skip-waiting") {
|
||||
// The tab's "Reload" for a new build: take over now instead of waiting
|
||||
// for every tab of the old build to close.
|
||||
|
||||
+34
-1
@@ -19,7 +19,7 @@ import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } fro
|
||||
import { isEmbedded, notifyParent } from '@/lib/iframe-bridge';
|
||||
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager';
|
||||
import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope';
|
||||
import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData, runBeforeSignOut } from '@/lib/sign-out-cleanup';
|
||||
import { broadcastSignOut, onSignedOutElsewhere, passSignOutGates, purgeSignedOutData, runBeforeSignOut } from '@/lib/sign-out-cleanup';
|
||||
import { forgetPrincipals } from '@/lib/delegation/known-principals';
|
||||
import { useSearchHistoryStore } from './search-history-store';
|
||||
import { useCalendarNotificationStore } from './calendar-notification-store';
|
||||
@@ -1309,6 +1309,25 @@ function forgetCalendarSubscriptions(client: IJMAPClient): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A client for an account that couldn't be restored because the server
|
||||
* can't be reached, from the session kept with its offline data (#523);
|
||||
* null without offline mode for that account. Loaded on demand: offline
|
||||
* mode imports this store.
|
||||
*/
|
||||
async function startOfflineClient(accountId: string, serverUrl: string, username: string): Promise<JMAPClient | null> {
|
||||
if (IS_LITE) return null;
|
||||
try {
|
||||
const offline = await import('@/lib/offline/offline-mode');
|
||||
const session = await offline.offlineSessionFor(accountId);
|
||||
if (!session) return null;
|
||||
offline.markStartedOffline();
|
||||
return JMAPClient.fromOfflineSession(serverUrl, username, session);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function performFullLogout(set: (state: Partial<AuthState>) => void): void {
|
||||
useSettingsStore.getState().disableSync();
|
||||
// With settings sync on, the local settings and templates are a copy of the
|
||||
@@ -2262,6 +2281,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
const userInitiated = options?.endProviderSession !== false;
|
||||
const endProviderSession = userInitiated && !wasDemoMode && shouldEndProviderSession(account);
|
||||
|
||||
// Signing out on purpose may be stopped by the user (unsent mail in
|
||||
// the offline outbox would be lost); an automatic one may not.
|
||||
if (userInitiated && !(await passSignOutGates(accountId ?? 'all'))) return;
|
||||
|
||||
await beforeLogoutHooks(accountId ?? 'all');
|
||||
|
||||
// From here on nothing may renew this slot's session; the cleanup
|
||||
@@ -2425,6 +2448,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
},
|
||||
|
||||
logoutAll: async (options) => {
|
||||
if (options?.endProviderSession !== false && !(await passSignOutGates('all'))) return;
|
||||
await beforeLogoutHooks('all');
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
@@ -2848,6 +2872,15 @@ export const useAuthStore = create<AuthState>()(
|
||||
// treatment as the rate-limit case above; only a definitive
|
||||
// rejection below evicts.
|
||||
if (isTransientAuthError(err)) {
|
||||
// Offline mode (#523): with this account's mail kept on the
|
||||
// device, start from it. The client answers from the device
|
||||
// only; a reload once the server is back signs in as usual.
|
||||
const offlineClient = await startOfflineClient(account.id, account.serverUrl, account.username);
|
||||
if (offlineClient) {
|
||||
clients.set(account.id, offlineClient);
|
||||
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
|
||||
return;
|
||||
}
|
||||
accountStore.updateAccount(account.id, {
|
||||
isConnected: false,
|
||||
hasError: true,
|
||||
|
||||
+26
-4
@@ -36,6 +36,7 @@ import { mailAccountKind } from "@/lib/plugin-sandbox/account-kind";
|
||||
import { SendCancelledCleanupError } from "@/lib/send-failure-message";
|
||||
import type { StorageQuota } from "@/lib/quota";
|
||||
import { isArchiveActionEnabled } from "@/lib/archive-gate";
|
||||
import { getOutboxHooks } from "@/lib/offline/outbox-hooks";
|
||||
import {
|
||||
READ_FILTER, UNREAD_FILTER, UNREAD_SECTION_SIZE, andFilters, isUnreadFirstView, mergeUnreadSection,
|
||||
openSectionRows, sectionFromUnreadRows, unreadSectionRowCount, withoutReached, type UnreadSection,
|
||||
@@ -444,8 +445,12 @@ interface EmailStore {
|
||||
/** `ownerAccountId`: the JMAP account a link names for the message (#1351). */
|
||||
fetchEmailContent: (client: IJMAPClient, emailId: string, ownerAccountId?: string) => Promise<Email | null>;
|
||||
fetchQuota: (client: IJMAPClient) => Promise<void>;
|
||||
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record<string, string>; labelHeaders?: Record<string, string>; localAccountId?: string; draftAccountId?: string }) => Promise<SendEmailResult>;
|
||||
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise<SendEmailResult>;
|
||||
/**
|
||||
* `options.outbox`: a send from the composer or quick reply, which offline
|
||||
* mode may keep in its outbox while the server can't be reached (#523).
|
||||
*/
|
||||
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record<string, string>; labelHeaders?: Record<string, string>; localAccountId?: string; draftAccountId?: string; outbox?: boolean }) => Promise<SendEmailResult>;
|
||||
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[], options?: { outbox?: boolean }) => Promise<SendEmailResult>;
|
||||
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
|
||||
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean, emailKey?: string) => Promise<boolean>;
|
||||
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
|
||||
@@ -4293,9 +4298,16 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
},
|
||||
|
||||
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options) => {
|
||||
// Offline (#523): everything up to here ran at Send (checks, label,
|
||||
// signature, plugin checks); the finished send waits in the outbox.
|
||||
const outbox = options?.outbox ? getOutboxHooks() : null;
|
||||
const queueArgs = () => [to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options];
|
||||
if (outbox?.shouldQueue(client)) return outbox.queueEmail(client, queueArgs());
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options);
|
||||
// Files attached while offline are uploaded now that the server answers.
|
||||
const sendAttachments = attachments && getOutboxHooks() ? await getOutboxHooks()!.resolveHeldAttachments(client, attachments) : attachments;
|
||||
const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, sendAttachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options);
|
||||
// Each send leaves a submission record; sweep the ones that can go
|
||||
// (throttled per account by the client).
|
||||
void client.pruneEmailSubmissions();
|
||||
@@ -4323,6 +4335,12 @@ export const useEmailStore = create<EmailStore>((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)) {
|
||||
set({ isLoading: false });
|
||||
return outbox.queueEmail(client, queueArgs());
|
||||
}
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to send email",
|
||||
isLoading: false
|
||||
@@ -4331,7 +4349,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
sendRawEmail: async (client, rawMimeBlob, identityId, delayedUntil, envelopeRecipients) => {
|
||||
sendRawEmail: async (client, rawMimeBlob, identityId, delayedUntil, envelopeRecipients, options) => {
|
||||
// A plugin's send completing the composer's (S/MIME, PGP): signed and
|
||||
// encrypted with the user present, so the finished message can wait.
|
||||
const outbox = options?.outbox ? getOutboxHooks() : null;
|
||||
if (outbox?.shouldQueue(client)) return outbox.queueRaw(client, rawMimeBlob, { identityId, delayedUntil, envelopeRecipients });
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const mailboxes = await client.getMailboxes();
|
||||
|
||||
Reference in New Issue
Block a user