mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
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
96 lines
4.6 KiB
TypeScript
96 lines
4.6 KiB
TypeScript
'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); one held because the
|
|
* organisation's rules changed can only be deleted (and written again).
|
|
*/
|
|
export function OutboxList({ heading = true }: { heading?: boolean }) {
|
|
const t = useTranslations('offline.outbox');
|
|
const tErrors = useTranslations('offline.errors');
|
|
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<OutboxRow | null>(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?.code ? tErrors(row.error.code) : (row.error?.text ?? '') });
|
|
case 'uncertain': return t('status_uncertain');
|
|
case 'held': return t('status_held');
|
|
default: return t('status_waiting');
|
|
}
|
|
};
|
|
|
|
const retry = async (row: OutboxRow) => {
|
|
await retryOutboxEntry(row.id);
|
|
await refreshQueueState();
|
|
if (!isOfflineNow()) scheduleFlushAll(0);
|
|
};
|
|
|
|
return (
|
|
<div className={heading ? 'pt-2' : undefined} data-testid="offline-outbox">
|
|
{heading && <h4 className="text-sm font-medium text-foreground">{t('title')}</h4>}
|
|
<p className="mt-1 text-xs text-muted-foreground">{t('description')}</p>
|
|
{rows.length === 0 ? (
|
|
<p className="mt-3 text-sm text-muted-foreground" data-testid="offline-outbox-empty">{t('empty')}</p>
|
|
) : (
|
|
<ul className="mt-3 divide-y divide-border rounded-md border border-border" aria-label={t('title')}>
|
|
{rows.map((row) => (
|
|
<li key={row.id} className="flex flex-col gap-2 px-3 py-2.5 sm:flex-row sm:items-start sm:justify-between" data-testid="offline-outbox-row" data-status={row.status}>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium text-foreground">{row.subject || t('no_subject')}</p>
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
{t('to', { recipients: row.to.join(', ') })}
|
|
{several && <> · {accounts.find((a) => a.id === row.account)?.label ?? row.account}</>}
|
|
</p>
|
|
<p className={row.status === 'failed' || row.status === 'uncertain' || row.status === 'held' ? 'text-xs text-destructive [overflow-wrap:anywhere]' : 'text-xs text-muted-foreground'}>
|
|
{status(row)} · {format.dateTime(row.createdAt, { dateStyle: 'short', timeStyle: 'short' })}
|
|
</p>
|
|
</div>
|
|
<div className="flex shrink-0 gap-2">
|
|
{(row.status === 'failed' || row.status === 'uncertain') && (
|
|
<Button variant="outline" size="sm" aria-label={t('retry_label', { subject: row.subject || t('no_subject') })} onClick={() => void retry(row)}>{t('retry')}</Button>
|
|
)}
|
|
{row.status !== 'sending' && (
|
|
<Button variant="outline" size="sm" aria-label={t('delete_label', { subject: row.subject || t('no_subject') })} onClick={() => setDeleting(row)}>{t('delete')}</Button>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{changes > 0 && (
|
|
<p className="mt-2 text-xs text-muted-foreground" data-testid="offline-pending-changes">{t('changes', { count: changes })}</p>
|
|
)}
|
|
<ConfirmDialog
|
|
isOpen={!!deleting}
|
|
onClose={() => 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"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|