Merge pull request #1273 from PicPeak/fix/1262-email-queue-visibility

fix(email): show a queue nobody is working instead of reporting all-clear (#1262)
This commit is contained in:
Paul Nothaft
2026-09-02 16:46:28 +02:00
committed by GitHub
14 changed files with 1031 additions and 72 deletions
@@ -10,6 +10,7 @@ import {
Ruler,
CalendarClock,
RotateCw,
AlertTriangle,
} from 'lucide-react';
import { Button, Card, Input } from '../../../components/common';
import { useTranslation } from 'react-i18next';
@@ -585,12 +586,30 @@ export const StatusTab: React.FC<StatusTabProps> = ({
</div>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.expirationCheckerDesc')}</p>
</div>
{/* #1262 — this card used to render a green check unconditionally,
against an API field that was itself the literal 'active'. Both
ends now tell the truth: a stopped or bailing processor is the
reason queued mail never arrives, and this is one of the two
places an admin looks to find that out. */}
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('settings.systemStatus.emailProcessor')}</p>
<CheckCircle className="w-5 h-5 text-green-600" />
{systemStatus?.services?.emailProcessor?.status === 'active' ? (
<CheckCircle className="w-5 h-5 text-green-600" />
) : (
<AlertTriangle className="w-5 h-5 text-red-600" />
)}
</div>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.emailProcessorDesc')}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">
{systemStatus?.services?.emailProcessor?.status === 'stopped'
? t('settings.systemStatus.emailProcessorStopped',
'Not running — queued emails are written but nothing sends them.')
: systemStatus?.services?.emailProcessor?.status === 'degraded'
? t('settings.systemStatus.emailProcessorDegraded',
'Running, but the last pass could not send: {{error}}',
{ error: systemStatus?.services?.emailProcessor?.lastError })
: t('settings.systemStatus.emailProcessorDesc')}
</p>
</div>
</div>
+28 -2
View File
@@ -107,7 +107,30 @@
"error": "Fehler",
"queued": "Eingereiht",
"actions": "Aktionen"
}
},
"emptyNotAllClear": "Nichts ist fehlgeschlagen — beachten Sie aber die Hinweise oben."
},
"waitingEmails": {
"title": "Wartet auf Versand",
"empty": "Nichts in Wartestellung — die Warteschlange wird abgearbeitet.",
"description": "Vor über 10 Minuten eingereiht, jetzt fällig und weiterhin nicht versendet. Diese E-Mails sind nicht fehlgeschlagen — es hat niemand versucht, sie zu senden.",
"neverAttempted": "kein Versuch",
"attempted": "{{count}} Versuch(e), letzter Fehler: {{error}}",
"unknownError": "unbekannt",
"col": {
"attempts": "Versuche"
},
"emptyButUnworked": "Noch ist nichts überfällig, es sendet aber auch nichts — siehe Prozessor oben. Alles ab jetzt Eingereihte bleibt hier liegen.",
"truncated": "Die Warteschlange ist zu groß, um sie vollständig zu prüfen — in den gelesenen Zeilen war nichts überfällig, das ist aber keine Entwarnung."
},
"processor": {
"title": "E-Mail-Warteschlangen-Prozessor",
"running": "Läuft.",
"stopped": "Läuft auf dieser Instanz nicht. Eingereihte E-Mails werden in die Datenbank geschrieben, aber niemand versendet sie.",
"degraded": "Läuft, aber der letzte Durchlauf konnte nicht senden: {{error}}",
"lastRun": "Letzter Durchlauf {{when}}",
"neverRan": "Seit dem Start dieser Instanz nicht gelaufen.",
"lastResult": "{{sent}} gesendet, {{failed}} fehlgeschlagen"
}
},
"common": {
@@ -1324,6 +1347,7 @@
"resetGalleryPassword": "Galerie-Passwort zurücksetzen",
"resendCreationEmail": "Erstellungs-E-Mail erneut senden",
"creationEmailResent": "Die Erstellungs-E-Mail wurde zur Warteschlange hinzugefügt",
"emailQueuedHint": "Der Warteschlangen-Prozessor versendet sie — prüfen Sie den Systemzustand, falls sie nicht ankommt.",
"failedToResendEmail": "Fehler beim erneuten Senden der Erstellungs-E-Mail",
"photoStatistics": "Fotostatistiken",
"managePhotos": "Fotos verwalten",
@@ -1944,7 +1968,9 @@
"pending": "Ausstehend",
"sent": "Gesendet",
"failed": "Fehlgeschlagen",
"lastUpdate": "Letzte Aktualisierung"
"lastUpdate": "Letzte Aktualisierung",
"emailProcessorStopped": "Läuft nicht — eingereihte E-Mails werden geschrieben, aber niemand versendet sie.",
"emailProcessorDegraded": "Läuft, aber der letzte Durchlauf konnte nicht senden: {{error}}"
},
"photoDimensions": {
"title": "Foto-Abmessungen",
+28 -2
View File
@@ -107,7 +107,30 @@
"error": "Error",
"queued": "Queued",
"actions": "Actions"
}
},
"emptyNotAllClear": "Nothing has failed — but see above."
},
"waitingEmails": {
"title": "Waiting to send",
"empty": "Nothing waiting — the queue is being worked.",
"description": "Queued more than 10 minutes ago, due now, and still unsent. These have not failed — nothing has tried to send them.",
"neverAttempted": "never attempted",
"attempted": "{{count}} attempt(s), last error: {{error}}",
"unknownError": "unknown",
"col": {
"attempts": "Attempts"
},
"emptyButUnworked": "Nothing is overdue yet, but nothing is sending either — see the processor above. Anything queued from now on will sit here.",
"truncated": "The pending queue is too large to check in full — nothing overdue was found in the rows read, but this is not an all-clear."
},
"processor": {
"title": "Email queue processor",
"running": "Running.",
"stopped": "Not running on this instance. Queued emails are written to the database but nothing is sending them.",
"degraded": "Running, but the last pass could not send: {{error}}",
"lastRun": "Last pass {{when}}",
"neverRan": "Has not run since this instance started.",
"lastResult": "{{sent}} sent, {{failed}} failed"
}
},
"common": {
@@ -820,6 +843,7 @@
"resetGalleryPassword": "Reset Gallery Password",
"resendCreationEmail": "Resend Creation Email",
"creationEmailResent": "Creation email has been queued for sending",
"emailQueuedHint": "The queue processor sends it — check System health if it does not arrive.",
"failedToResendEmail": "Failed to resend creation email",
"photoStatistics": "Photo Statistics",
"totalPhotos": "Total Photos",
@@ -1307,7 +1331,9 @@
"pending": "Pending",
"sent": "Sent",
"failed": "Failed",
"lastUpdate": "Last update"
"lastUpdate": "Last update",
"emailProcessorStopped": "Not running — queued emails are written but nothing sends them.",
"emailProcessorDegraded": "Running, but the last pass could not send: {{error}}"
},
"photoDimensions": {
"title": "Photo Dimensions",
@@ -316,11 +316,13 @@ export const EventDetailsPage: React.FC = () => {
mutationFn: (password?: string) =>
eventsService.sendGalleryEmail(parseInt(id!), password ? { password } : undefined),
onSuccess: (result) => {
// #1262 — queueing is not delivery, and a queue nobody is working
// reports no failure at all. Point at where the queue is visible.
toast.success(
t('events.sendGalleryEmail.success', {
`${t('events.sendGalleryEmail.success', {
recipient: result.recipient,
defaultValue: 'Gallery email queued to {{recipient}}.',
}),
})} ${t('events.emailQueuedHint', 'The queue processor sends it — check System health if it does not arrive.')}`,
);
setShowSendEmailDialog(false);
},
+196 -50
View File
@@ -2,15 +2,22 @@
* Admin → System health. Aggregates background failures that would
* otherwise go unnoticed. v1: stuck/failed outbound emails (the queue
* processor gave up or exhausted retries), with retry + dismiss.
*
* #1262 — "no failures" was being read as "everything went out". It is not the
* same claim: a queue nobody is working produces no failures at all, because
* every row sits at status='pending' with retry_count 0. So the page now leads
* with what the processor itself last did, and lists due-but-unsent emails
* next to the failed ones. The all-clear only shows when both are empty and
* the processor is running.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { AlertCircle, RefreshCw, Trash2, CheckCircle } from 'lucide-react';
import { AlertCircle, RefreshCw, Trash2, CheckCircle, Clock, Mail, MailX } from 'lucide-react';
import { Button, Card, Loading } from '../../components/common';
import { useMutationWithToast } from '../../hooks';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { systemHealthService } from '../../services/systemHealth.service';
import { systemHealthService, type StuckEmail } from '../../services/systemHealth.service';
export const SystemHealthPage: React.FC = () => {
const { t } = useTranslation();
@@ -35,6 +42,97 @@ export const SystemHealthPage: React.FC = () => {
});
const stuckEmails = data?.stuckEmails ?? [];
const waitingEmails = data?.waitingEmails ?? [];
const processor = data?.processor;
// The endpoint stops reading after a bounded number of pending rows. Past
// that, an empty waiting list means "nothing found yet", not "nothing" — so
// it must not turn into a green check.
const scanTruncated = data?.scanTruncated ?? false;
// The processor is only "fine" when it has been started AND its last pass
// didn't bail. A started-but-erroring processor is the case that used to
// read as healthy, so it gets its own state rather than folding into either.
const processorState: 'ok' | 'degraded' | 'stopped' = !processor
? 'ok'
: !processor.started
? 'stopped'
: processor.lastError
? 'degraded'
: 'ok';
/**
* `actions` is off for waiting rows, and deliberately so. Retry would
* rewrite a row that is already pending / retry_count 0 / unscheduled to the
* state it is in, and Dismiss would permanently delete an email that has not
* failed and will still go out once the processor recovers — a click on a
* health warning silently cancelling a customer's mail.
*/
const emailTable = (rows: StuckEmail[], showError: boolean, actions = true) => (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.recipient', 'Recipient')}</th>
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.type', 'Type')}</th>
<th className="px-3 py-2 text-left">
{showError
? t('systemHealth.stuckEmails.col.error', 'Error')
: t('systemHealth.waitingEmails.col.attempts', 'Attempts')}
</th>
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.queued', 'Queued')}</th>
{actions && (
<th className="px-3 py-2 text-right">{t('systemHealth.stuckEmails.col.actions', 'Actions')}</th>
)}
</tr>
</thead>
<tbody>
{rows.map((m) => (
<tr key={m.id} className="border-t border-neutral-200 dark:border-neutral-700 align-top">
<td className="px-3 py-2 break-all">{m.recipientEmail}</td>
<td className="px-3 py-2 font-mono text-xs">{m.emailType}</td>
<td className="px-3 py-2 max-w-xs">
{showError ? (
<span className="text-xs text-red-700 dark:text-red-400 break-words">
{m.errorMessage || t('systemHealth.stuckEmails.noError', 'retries exhausted')}
</span>
) : (
<span className="text-xs text-neutral-600 dark:text-neutral-400">
{m.retryCount > 0
? t('systemHealth.waitingEmails.attempted', '{{count}} attempt(s), last error: {{error}}', {
count: m.retryCount,
error: m.errorMessage || t('systemHealth.waitingEmails.unknownError', 'unknown'),
})
: t('systemHealth.waitingEmails.neverAttempted', 'never attempted')}
</span>
)}
</td>
<td className="px-3 py-2 whitespace-nowrap">{m.createdAt ? fmtDateTime(m.createdAt) : '—'}</td>
{actions && (
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1">
<Button variant="outline" size="sm"
isLoading={retryMutation.isPending && retryMutation.variables === m.id}
onClick={() => retryMutation.mutate(m.id)}
leftIcon={<RefreshCw className="w-3.5 h-3.5" />}>
{t('systemHealth.retry', 'Retry')}
</Button>
<button type="button"
aria-label={t('systemHealth.dismiss', 'Dismiss') as string}
onClick={() => dismissMutation.mutate(m.id)}
className="p-1.5 text-neutral-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
return (
<div className="container py-6">
@@ -45,6 +143,94 @@ export const SystemHealthPage: React.FC = () => {
</p>
</div>
{/* Queue processor. Listed first because when this is stopped, every
other count on the page is explained by it — and a stopped processor
shows no failures at all, which is what made it invisible. */}
{!isLoading && processor && (
<Card padding="lg" className="mb-4">
<div className="flex items-start gap-3">
{processorState === 'ok'
? <Mail className="w-5 h-5 mt-0.5 text-green-600 dark:text-green-400 shrink-0" />
: <MailX className="w-5 h-5 mt-0.5 text-red-600 dark:text-red-400 shrink-0" />}
<div className="min-w-0">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('systemHealth.processor.title', 'Email queue processor')}
</h2>
<p className={`text-sm mt-0.5 ${
processorState === 'ok'
? 'text-neutral-600 dark:text-neutral-400'
: 'text-red-700 dark:text-red-400'
}`}>
{processorState === 'stopped'
? t('systemHealth.processor.stopped',
'Not running on this instance. Queued emails are written to the database but nothing is sending them.')
: processorState === 'degraded'
? t('systemHealth.processor.degraded',
'Running, but the last pass could not send: {{error}}', { error: processor.lastError })
: t('systemHealth.processor.running', 'Running.')}
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{processor.lastRunAt
? t('systemHealth.processor.lastRun', 'Last pass {{when}}', { when: fmtDateTime(processor.lastRunAt) })
: t('systemHealth.processor.neverRan', 'Has not run since this instance started.')}
{processor.lastResult && (
<> {' · '}
{t('systemHealth.processor.lastResult', '{{sent}} sent, {{failed}} failed', {
sent: processor.lastResult.sent,
failed: processor.lastResult.failed,
})}
</>
)}
</p>
</div>
</div>
</Card>
)}
{/* Due but unsent. Distinct from failed: nothing went wrong with these,
they were simply never picked up. */}
<Card padding="lg" className="mb-4">
<div className="flex items-center gap-2 mb-3">
<Clock className="w-5 h-5 text-amber-500" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('systemHealth.waitingEmails.title', 'Waiting to send')}
</h2>
{!isLoading && (
<span className="ml-1 text-sm text-neutral-500 dark:text-neutral-400">({waitingEmails.length})</span>
)}
</div>
{isLoading ? <Loading /> : waitingEmails.length === 0 ? (
// "Nothing waiting" is only reassuring when something is working the
// queue. A processor that stopped a minute ago has no waiting rows
// yet either — the grace window has not elapsed — and a green check
// there is the same false all-clear this page exists to remove.
processorState === 'ok' && !scanTruncated ? (
<div className="flex items-center gap-2 text-sm text-green-700 dark:text-green-400 py-6">
<CheckCircle className="w-5 h-5" />
{t('systemHealth.waitingEmails.empty', 'Nothing waiting — the queue is being worked.')}
</div>
) : (
<div className="flex items-center gap-2 text-sm text-amber-700 dark:text-amber-400 py-6">
<AlertCircle className="w-5 h-5" />
{scanTruncated
? t('systemHealth.waitingEmails.truncated',
'The pending queue is too large to check in full — nothing overdue was found in the rows read, but this is not an all-clear.')
: t('systemHealth.waitingEmails.emptyButUnworked',
'Nothing is overdue yet, but nothing is sending either — see the processor above. Anything queued from now on will sit here.')}
</div>
)
) : (
<>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-3">
{t('systemHealth.waitingEmails.description',
'Queued more than 10 minutes ago, due now, and still unsent. These have not failed — nothing has tried to send them.')}
</p>
{emailTable(waitingEmails, false, false)}
</>
)}
</Card>
<Card padding="lg">
<div className="flex items-center gap-2 mb-3">
<AlertCircle className="w-5 h-5 text-amber-500" />
@@ -59,55 +245,15 @@ export const SystemHealthPage: React.FC = () => {
{isLoading ? <Loading /> : stuckEmails.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-green-700 dark:text-green-400 py-6">
<CheckCircle className="w-5 h-5" />
{t('systemHealth.stuckEmails.empty', 'No stuck or failed emails — all clear.')}
{/* "all clear" is a claim about the whole queue, so it is only
allowed when the whole queue is clear. With mail waiting or a
processor that is not working, this section is still empty but
the system is not fine. */}
{waitingEmails.length === 0 && processorState === 'ok' && !scanTruncated
? t('systemHealth.stuckEmails.empty', 'No stuck or failed emails — all clear.')
: t('systemHealth.stuckEmails.emptyNotAllClear', 'Nothing has failed — but see above.')}
</div>
) : (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.recipient', 'Recipient')}</th>
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.type', 'Type')}</th>
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.error', 'Error')}</th>
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.queued', 'Queued')}</th>
<th className="px-3 py-2 text-right">{t('systemHealth.stuckEmails.col.actions', 'Actions')}</th>
</tr>
</thead>
<tbody>
{stuckEmails.map((m) => (
<tr key={m.id} className="border-t border-neutral-200 dark:border-neutral-700 align-top">
<td className="px-3 py-2 break-all">{m.recipientEmail}</td>
<td className="px-3 py-2 font-mono text-xs">{m.emailType}</td>
<td className="px-3 py-2 max-w-xs">
<span className="text-xs text-red-700 dark:text-red-400 break-words">
{m.errorMessage || t('systemHealth.stuckEmails.noError', 'retries exhausted')}
</span>
</td>
<td className="px-3 py-2 whitespace-nowrap">{m.createdAt ? fmtDateTime(m.createdAt) : '—'}</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1">
<Button variant="outline" size="sm"
isLoading={retryMutation.isPending && retryMutation.variables === m.id}
onClick={() => retryMutation.mutate(m.id)}
leftIcon={<RefreshCw className="w-3.5 h-3.5" />}>
{t('systemHealth.retry', 'Retry')}
</Button>
<button type="button"
aria-label={t('systemHealth.dismiss', 'Dismiss') as string}
onClick={() => dismissMutation.mutate(m.id)}
className="p-1.5 text-neutral-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
) : emailTable(stuckEmails, true)}
</Card>
</div>
);
@@ -193,7 +193,10 @@ export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPass
onClick={async () => {
try {
await eventsService.resendCreationEmail(event.id);
toast.success(t('events.creationEmailResent'));
// #1262 — "queued" was being read as "delivered". Queueing only
// writes an email_queue row; say where to look when it doesn't
// turn up, because a queue nobody is working raises no failure.
toast.success(`${t('events.creationEmailResent')} ${t('events.emailQueuedHint', 'The queue processor sends it — check System health if it does not arrive.')}`);
} catch {
toast.error(t('events.failedToResendEmail'));
}
+3 -1
View File
@@ -164,7 +164,9 @@ export interface SystemStatus {
services: {
fileWatcher: { status: string };
expirationChecker: { status: string };
emailProcessor: { status: string };
// 'active' | 'degraded' | 'stopped' (#1262). Was a hardcoded 'active'
// until the processor started reporting what it actually did.
emailProcessor: { status: string; lastRunAt?: string | null; lastError?: string | null };
};
timestamp: string;
}
+19 -1
View File
@@ -1,6 +1,10 @@
/**
* Admin → System health. Surfaces background failures (v1: stuck/failed
* outbound emails) so they don't sit unnoticed, with retry/dismiss.
*
* #1262 added `waitingEmails` and `processor`: a queue nobody is working
* produces no failures at all, so "no failures" was not the same claim as
* "everything went out".
*/
import { api } from '../config/api';
@@ -14,9 +18,23 @@ export interface StuckEmail {
createdAt: string;
}
/** What the queue processor last did — the difference between "idle" and "dead". */
export interface EmailProcessorStatus {
started: boolean;
lastRunAt: string | null;
lastResult: { processed: number; sent: number; failed: number } | null;
lastError: string | null;
}
export interface SystemHealthFailures {
stuckEmails: StuckEmail[];
counts: { stuckEmails: number };
/** Due, under the retry cap, and still unsent — nobody picked them up. */
waitingEmails: StuckEmail[];
processor: EmailProcessorStatus;
/** The pending queue was larger than the endpoint reads — an empty
* `waitingEmails` then means "nothing found yet", not "nothing". */
scanTruncated?: boolean;
counts: { stuckEmails: number; waitingEmails: number; pendingScanned?: number };
}
export const systemHealthService = {