Files
webmail/components/offline/offline-banner.tsx
T
Linus Rath 4b60fcc1e6 fix: make the offline outbox never send twice, never lose mail silently and never skip a plugin #523
The review of offline mode found ways to send a message twice, to lose
unsent mail without a word, and to send mail without its signing,
encryption or check plugin. This fixes them and the smaller findings:

- each outbox message is taken for sending in one database transaction
  under a Web Lock per account; only a failure before the submission
  request (SendNotStartedError from the client) puts it back, any other
  one marks it "may have gone out"; read-backs after the submission no
  longer fail a send; a send of a closed tab turns uncertain after a minute
- every sign-out the user didn't confirm names the deleted messages on
  the sign-in page, or in a toast when another account stays signed in
- nothing is queued while an enabled plugin isn't running (after a start
  without the network none can); the composer says why
- the outbox waits for a fresh policy and holds messages the rules have
  since made stricter (a required label, a required send check)
- another tab switching offline mode off sticks: only switching it on
  may create the database
- only attachments of kept messages are kept, filed with their message,
  deleted with it and under the cap between syncs; file names and types
  are sealed; folders that went away leave the scope, and a folder that
  couldn't be asked keeps its mail without holding the others back
- a group scope that forbids offline mode waits until the account's
  groups are known; one server down no longer holds another's mail
- an offline start only when the server is really unreachable, and the
  real sign-in follows by itself once it answers
- the service worker files pages and chunks under the build that served
  them, and the shell list of another build isn't kept under this one
- a raw send counts as the composer's only from the plugin running its
  takeover, for that identity
- errors and refusals are in all languages; the banner tells "offline"
  from "server can't be reached"; outbox buttons name their message;
  changes and messages go in the order they were made
2026-11-01 14:19:54 +01:00

60 lines
2.5 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { CloudOff } from '@/components/icons';
import { Dialog } from '@/components/ui/dialog';
import { IS_LITE } from '@/lib/lite';
import { serverKey, useOfflineStore } from '@/lib/offline/state';
import { useAuthStore } from '@/stores/auth-store';
import { OutboxList } from './outbox-list';
/**
* A strip across the top of the apps while offline mode (#523) answers from
* this device: the browser is offline (or the mail server can't be
* reached), and how many messages wait in the outbox. The count opens the
* outbox in a dialog, which works without a network (a page change might
* not). Shown only for an account that keeps mail here.
*/
export function OfflineBanner() {
const t = useTranslations('offline');
const account = useAuthStore((s) => s.activeAccountId);
const server = useAuthStore((s) => s.serverUrl);
const active = useOfflineStore((s) => !!account && s.enabled.includes(account));
const browserOffline = useOfflineStore((s) => s.browserOffline || s.startedOffline);
const serverDown = useOfflineStore((s) => !!server && s.downServers.includes(serverKey(server)));
const waiting = useOfflineStore((s) => s.outbox.length);
const [open, setOpen] = useState(false);
const offline = browserOffline || serverDown;
if (IS_LITE || !active || (!offline && waiting === 0 && !open)) return null;
return (
<>
<div
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" role="status">
<CloudOff className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 [overflow-wrap:anywhere]">{browserOffline ? t('banner.offline') : t('banner.server_down')}</span>
</span>
)}
{waiting > 0 && (
<button
type="button"
data-testid="offline-banner-outbox"
aria-haspopup="dialog"
onClick={() => setOpen(true)}
className="min-h-11 rounded-md px-1 font-medium underline underline-offset-2 hover:bg-foreground/5 sm:min-h-0"
>
{t('banner.outbox', { count: waiting })}
</button>
)}
</div>
<Dialog isOpen={open} onClose={() => setOpen(false)} title={t('outbox.title')} testId="offline-outbox-dialog">
<OutboxList heading={false} />
</Dialog>
</>
);
}