Files
webmail/components/offline/offline-host.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

91 lines
3.4 KiB
TypeScript

'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 { confirmLoss, 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.code ? tr(`errors.${notice.error.code}`) : notice.error.text,
});
break;
case 'held':
toast.warning(tr('toast.held', { subject: notice.subject || tr('outbox.no_subject') }));
break;
case 'lost':
toast.warning(tr('toast.lost', { subjects: notice.subjects.map((s) => s || tr('outbox.no_subject')).join(', ') }), { duration: 0 });
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;
const ok = await confirmRef.current({
title: tr('signout.title'),
message: tr('signout.message', { count: unsent }),
confirmText: tr('signout.confirm'),
variant: 'destructive',
});
// The user agreed: no word about these messages afterwards.
if (ok) confirmLoss(accountId);
return ok;
});
return () => {
removeGate();
stop();
setOfflineNotifier(() => {});
};
}, []);
return <ConfirmDialog {...dialogProps} />;
}