From db5ac82f539282bef96370d20087c51070dd9957 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:46:11 +0100 Subject: [PATCH] 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. --- app/(main)/[locale]/layout.tsx | 3 + app/(main)/[locale]/login/page.tsx | 6 + app/api/offline/shell/route.ts | 37 + components/email/email-composer.tsx | 3 + components/icons/index.ts | 1 + components/mail/mail-app.tsx | 8 +- .../__tests__/offline-settings.test.tsx | 76 +++ components/offline/offline-banner.tsx | 48 ++ components/offline/offline-host.tsx | 79 +++ components/offline/offline-settings.tsx | 94 +++ components/offline/outbox-list.tsx | 92 +++ components/plugins/app-top-banner-slot.tsx | 2 + components/settings/about-data-settings.tsx | 3 + lib/__tests__/sw-harness.ts | 12 +- lib/__tests__/sw-offline-shell.test.ts | 126 ++++ lib/jmap/client.ts | 164 ++++- lib/jmap/offline-hooks.ts | 69 ++ lib/jmap/types.ts | 5 + lib/offline/__tests__/cache.test.ts | 120 ++++ lib/offline/__tests__/outbox.test.ts | 222 ++++++ lib/offline/__tests__/responder.test.ts | 111 +++ lib/offline/cache.ts | 281 ++++++++ lib/offline/crypto.ts | 59 ++ lib/offline/db.ts | 162 +++++ lib/offline/flush.ts | 127 ++++ lib/offline/offline-mode.ts | 645 ++++++++++++++++++ lib/offline/outbox-hooks.ts | 37 + lib/offline/queue.ts | 178 +++++ lib/offline/responder.ts | 312 +++++++++ lib/offline/shell.ts | 50 ++ lib/offline/state.ts | 78 +++ lib/offline/sync.ts | 104 +++ lib/outgoing/compose-takeover.ts | 22 + lib/outgoing/composer-send-options.ts | 5 +- lib/plugin-after-send.ts | 2 + lib/plugin-sandbox/host-api.ts | 10 + lib/session-policy/sign-out-reason.ts | 27 +- lib/sign-out-cleanup.ts | 52 ++ locales/ar/common.json | 53 ++ locales/ca/common.json | 53 ++ locales/cs/common.json | 53 ++ locales/da/common.json | 53 ++ locales/de/common.json | 53 ++ locales/en/common.json | 53 ++ locales/es/common.json | 53 ++ locales/et/common.json | 53 ++ locales/fa/common.json | 53 ++ locales/fr/common.json | 53 ++ locales/he/common.json | 53 ++ locales/hu/common.json | 53 ++ locales/it/common.json | 53 ++ locales/ja/common.json | 53 ++ locales/ko/common.json | 53 ++ locales/lv/common.json | 53 ++ locales/mn/common.json | 53 ++ locales/nb/common.json | 53 ++ locales/nl/common.json | 53 ++ locales/pl/common.json | 53 ++ locales/pt/common.json | 53 ++ locales/ro/common.json | 53 ++ locales/ru/common.json | 53 ++ locales/sk/common.json | 53 ++ locales/th/common.json | 53 ++ locales/tr/common.json | 53 ++ locales/uk/common.json | 53 ++ locales/vi/common.json | 53 ++ locales/zh-TW/common.json | 53 ++ locales/zh/common.json | 53 ++ public/sw.js | 204 +++++- stores/auth-store.ts | 35 +- stores/email-store.ts | 30 +- 71 files changed, 5270 insertions(+), 21 deletions(-) create mode 100644 app/api/offline/shell/route.ts create mode 100644 components/offline/__tests__/offline-settings.test.tsx create mode 100644 components/offline/offline-banner.tsx create mode 100644 components/offline/offline-host.tsx create mode 100644 components/offline/offline-settings.tsx create mode 100644 components/offline/outbox-list.tsx create mode 100644 lib/__tests__/sw-offline-shell.test.ts create mode 100644 lib/jmap/offline-hooks.ts create mode 100644 lib/offline/__tests__/cache.test.ts create mode 100644 lib/offline/__tests__/outbox.test.ts create mode 100644 lib/offline/__tests__/responder.test.ts create mode 100644 lib/offline/cache.ts create mode 100644 lib/offline/crypto.ts create mode 100644 lib/offline/db.ts create mode 100644 lib/offline/flush.ts create mode 100644 lib/offline/offline-mode.ts create mode 100644 lib/offline/outbox-hooks.ts create mode 100644 lib/offline/queue.ts create mode 100644 lib/offline/responder.ts create mode 100644 lib/offline/shell.ts create mode 100644 lib/offline/state.ts create mode 100644 lib/offline/sync.ts create mode 100644 lib/outgoing/compose-takeover.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 8d5589501..9164a715f 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -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({ + {/* Offline mode isn't in Lite (no service worker there). */} + {!IS_LITE && } {/* Lite is a static bundle: no server keeps an archive there. */} diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 5cee22b98..e144984b8 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -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) && ( {tSessionLimits("drafts_not_saved")} )} + {sessionEnd?.outboxLost?.length ? ( + + {tOffline("signout.lost", { subjects: sessionEnd.outboxLost.map((s) => s || tOffline("outbox.no_subject")).join(", ") })} + + ) : null}

+ )} + + ); +} diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx new file mode 100644 index 000000000..85cc83289 --- /dev/null +++ b/components/offline/offline-host.tsx @@ -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 ; +} diff --git a/components/offline/offline-settings.tsx b/components/offline/offline-settings.tsx new file mode 100644 index 000000000..013e37ecb --- /dev/null +++ b/components/offline/offline-settings.tsx @@ -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 ( + + {!offlineSupported() ? ( +

{t('unsupported')}

+ ) : !allowed ? ( +
+

{t('keep_label')}

+

{t('forbidden')}

+ +
+ ) : ( + <> + + { + if (!account) return; + if (on) void enableOffline(account); + else if (unsent > 0) setConfirmOff(true); + else turnOff(); + }} + /> + +
+
+ {enabled && ( +

+ {syncing + ? t('syncing') + : lastSynced + ? t('usage_updated', { size: formatFileSize(usage), time: format.relativeTime(lastSynced, Date.now()) }) + : t('usage', { size: formatFileSize(usage) })} +

+ )} + + )} + + setConfirmOff(false)} + onConfirm={() => { setConfirmOff(false); turnOff(); }} + title={t('off_confirm_title')} + message={t('off_confirm_message', { count: unsent })} + confirmText={t('off_confirm')} + variant="destructive" + /> +
+ ); +} diff --git a/components/offline/outbox-list.tsx b/components/offline/outbox-list.tsx new file mode 100644 index 000000000..2fdc22f42 --- /dev/null +++ b/components/offline/outbox-list.tsx @@ -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(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 ( +
+

{t('title')}

+

{t('description')}

+ {rows.length === 0 ? ( +

{t('empty')}

+ ) : ( +
    + {rows.map((row) => ( +
  • +
    +

    {row.subject || t('no_subject')}

    +

    + {t('to', { recipients: row.to.join(', ') })} + {several && <> · {accounts.find((a) => a.id === row.account)?.label ?? row.account}} +

    +

    + {status(row)} · {format.dateTime(row.createdAt, { dateStyle: 'short', timeStyle: 'short' })} +

    +
    +
    + {(row.status === 'failed' || row.status === 'uncertain') && ( + + )} + {row.status !== 'sending' && ( + + )} +
    +
  • + ))} +
+ )} + {changes > 0 && ( +

{t('changes', { count: changes })}

+ )} + 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" + /> +
+ ); +} diff --git a/components/plugins/app-top-banner-slot.tsx b/components/plugins/app-top-banner-slot.tsx index 0115007a8..498ba4705 100644 --- a/components/plugins/app-top-banner-slot.tsx +++ b/components/plugins/app-top-banner-slot.tsx @@ -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 && } + {!embedded && } ); diff --git a/components/settings/about-data-settings.tsx b/components/settings/about-data-settings.tsx index 17db44064..e7f71d24d 100644 --- a/components/settings/about-data-settings.tsx +++ b/components/settings/about-data-settings.tsx @@ -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() { + +