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}