Offline access: read recent mail without a connection

An opt-in per device (Settings → Security → Offline access) installs a
service worker that keeps the newest 50 Inbox conversations, and the ones
opened recently, for reading when the server cannot be reached. While
online everything still comes from the server; the saved copy is only the
fallback, marked X-WM-Offline so the app knows it is offline.

Offline, an amber cloud next to the theme toggle says so and when mail was
last saved; actions that need the server are greyed out and say "Not
available offline"; conversations and folders not saved here are faded or
say so. A probe notices the server coming back, shows "Back online" and
refreshes everything. Saved mail is wiped on sign-out, on any 401, and when
another user signs in. nginx never caches /sw.js, so releases reach it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 18:54:17 +02:00
co-authored by Claude Opus 5.5
parent 139fff826e
commit a2c52f33a8
15 changed files with 1245 additions and 30 deletions
@@ -211,7 +211,7 @@ function ActionsMenu({
return (
<div ref={ref} className="relative">
<button
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs text-app-muted hover:bg-app-hover hover:text-app-fg"
className="wm-online-only flex items-center gap-1 rounded px-1.5 py-1 text-xs text-app-muted hover:bg-app-hover hover:text-app-fg"
onClick={() => setOpen((v) => !v)}
>
{t("mail.actions")}
@@ -343,7 +343,7 @@ function MessageItem({
const [showDetails, setShowDetails] = useState(false);
const replyBtn = (kind: string, label: string, icon: string) => (
<button
className="flex items-center gap-1.5 rounded-md border border-app-border px-3 py-1.5 text-xs text-app-fg hover:bg-app-hover"
className="wm-online-only flex items-center gap-1.5 rounded-md border border-app-border px-3 py-1.5 text-xs text-app-fg hover:bg-app-hover"
onClick={() => onReply(kind)}
>
<Icon name={icon} size={14} />
@@ -430,7 +430,7 @@ function MessageItem({
title={t("mail.markReadUnread")}
aria-label={t("mail.markReadUnread")}
onClick={onToggleRead}
className="grid h-4 w-4 place-items-center"
className="wm-online-only grid h-4 w-4 place-items-center"
>
<span
className={`h-3 w-3 rounded-full border ${
@@ -442,7 +442,7 @@ function MessageItem({
</button>
{canReply && (
<button
className="flex items-center gap-1 text-app-muted hover:text-app-fg"
className="wm-online-only flex items-center gap-1 text-app-muted hover:text-app-fg"
onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
>
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />