From bce556402540ecfbd54a0e0a4e5ba32816e05158 Mon Sep 17 00:00:00 2001
From: rcourtman
Date: Sun, 23 Aug 2026 09:05:54 +0100
Subject: [PATCH] fix notification terminal failure recovery
---
docs/API.md | 6 +
docs/TROUBLESHOOTING.md | 7 ++
.../v6/internal/subsystems/agent-lifecycle.md | 4 +
.../v6/internal/subsystems/alerts.md | 5 +
.../v6/internal/subsystems/api-contracts.md | 6 +
.../subsystems/frontend-primitives.md | 4 +
.../v6/internal/subsystems/notifications.md | 9 ++
.../internal/subsystems/storage-recovery.md | 3 +
frontend-modern/browser-verification.json | 36 +++---
frontend-modern/public/docs/API.md | 6 +
.../public/docs/TROUBLESHOOTING.md | 7 ++
.../src/api/__tests__/notifications.test.ts | 22 ++++
frontend-modern/src/api/notifications.ts | 27 ++++
.../alerts/AlertDeliveryHealthCard.test.tsx | 10 ++
.../alerts/AlertDeliveryHealthCard.tsx | 49 ++++++--
.../useAlertDestinationsTabState.test.tsx | 82 +++++++++++++
.../features/alerts/tabs/DestinationsTab.tsx | 4 +
.../alerts/useAlertDestinationsTabState.ts | 48 ++++++++
.../alertDestinationsPresentation.test.ts | 12 ++
.../src/utils/__tests__/docsLinks.test.ts | 14 +++
.../utils/alertDestinationsPresentation.ts | 18 +++
internal/api/alerting/notification_queue.go | 58 ++++++++-
.../notification_queue_additional_test.go | 37 ++++++
internal/api/route_inventory_test.go | 2 +
internal/api/router_routes_monitoring.go | 14 +++
internal/api/security_regression_test.go | 4 +
internal/notifications/queue.go | 116 ++++++++++++++++++
internal/notifications/queue_test.go | 67 ++++++++++
28 files changed, 651 insertions(+), 26 deletions(-)
diff --git a/docs/API.md b/docs/API.md
index 92c89dc3c..ca8aa29ca 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -600,6 +600,12 @@ Common reporting error codes:
- `GET /api/notifications/dlq` (admin)
- `POST /api/notifications/dlq/retry` (admin)
- `POST /api/notifications/dlq/delete` (admin)
+- `POST /api/notifications/terminal-failures/retry` (admin, `settings:write`)
+ - Returns every retained `failed` or `dlq` delivery to `pending` with a fresh
+ retry budget. Existing per-attempt delivery history is preserved.
+- `POST /api/notifications/terminal-failures/dismiss` (admin, `settings:write`)
+ - Marks every retained terminal delivery `cancelled`, clearing the active
+ queue-health warning without deleting delivery history.
---
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
index a6445d150..195809004 100644
--- a/docs/TROUBLESHOOTING.md
+++ b/docs/TROUBLESHOOTING.md
@@ -151,6 +151,10 @@ first step before platform-local cleanup.
- Open **Alerts → Notifications** first. Pulse shows a delivery warning when
failed or dead-lettered notifications remain in the persistent queue; a
missing queue-health read is shown as unavailable rather than healthy.
+- After correcting the destination, use **Retry retained deliveries**. Use
+ **Dismiss retained failures** only when those deliveries should not be sent.
+ Both actions preserve delivery history; do not delete `notification_queue.db`
+ to clear the warning.
- Check SMTP settings in **Alerts → Notifications**.
- Check logs: `docker logs pulse | grep email`.
- Ensure your SMTP provider allows the connection (e.g., Gmail App Passwords).
@@ -159,6 +163,9 @@ first step before platform-local cleanup.
- Check the delivery warning in **Alerts → Notifications** and use **Send test**
after correcting the destination. Recoverable retries do not trigger the
warning; retained terminal failures do.
+- If the test succeeds, use **Retry retained deliveries** to give the retained
+ items a fresh retry budget. Dismiss them only when delivery is no longer
+ wanted; neither action deletes the audit trail.
- Verify the URL is reachable from the Pulse server.
- If targeting private IPs, allow them in **Settings → System → Network → Webhook Security**.
- Check Pulse logs for HTTP status codes and response bodies.
diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
index b925bec97..0fddbdaa2 100644
--- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
+++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
@@ -192,6 +192,10 @@ Docker/Podman module attached to that host identity. The removal must delete
the associated runtime report surfaces and clear their active alerts instead
of leaving container alerts or inventory detached from a removed agent.
+Notification terminal-failure retry and dismissal are delivery-queue operator
+actions only. They do not renew agent enrollment, heartbeat, command-channel
+readiness, update observation, or any agent-lifecycle lease.
+
Proxmox node and backup-server setup may request an explicitly insecure
Unified Agent installer command. The choice must remain off by default, be
visible at the point where the command is generated, and affect both the
diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md
index 51a834b27..ed6463d29 100644
--- a/docs/release-control/v6/internal/subsystems/alerts.md
+++ b/docs/release-control/v6/internal/subsystems/alerts.md
@@ -258,6 +258,11 @@ frontend-primitives: row, mobile-card, global-default, and bulk-selection
icon-only actions must compose `ActionIconButton` for shared size, tone, focus,
title, and accessible-name behavior instead of rendering local `` plus
inline SVG shells in alert-owned runtime components.
+The notification-delivery health card is an alerts-owned presentation over
+notification-owned queue truth. When retained terminal failures exist, the
+Destinations surface exposes explicit retry and dismiss actions with
+consequence confirmations, refreshes health and delivery history after either
+action, and never instructs the operator to delete queue storage.
Alert runtime state has one explicit ownership boundary: `AlertConfig.enabled`
controls detector evaluation and in-product alert visibility, while
`AlertConfig.activationState` controls external notification delivery only.
diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md
index fd81d7b36..a572e2945 100644
--- a/docs/release-control/v6/internal/subsystems/api-contracts.md
+++ b/docs/release-control/v6/internal/subsystems/api-contracts.md
@@ -72,6 +72,12 @@ The same live update applies all four `schedule.grouping` fields atomically.
When grouping is disabled, pending and subsequent alerts are delivered
individually; the API boundary must not reduce that setting to the window or
grouping-key fields while silently ignoring `enabled`.
+`POST /api/notifications/terminal-failures/retry` and
+`POST /api/notifications/terminal-failures/dismiss` are admin,
+`settings:write` operator actions. Their strict response is
+`{"success": true, "affected": }`; frontend clients
+must normalize malformed counts to zero and must not consume the raw DLQ
+payload, which can contain notification configuration and content.
`DELETE /api/ai/patrol/suppressions/finding_{findingID}` is the canonical
reopen path for a dismissed Patrol finding. It removes the finding-backed
suppression row, preserves the operator note, clears dismissal state in both
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 2ef639435..4db71b1cb 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -22,6 +22,10 @@
Own reusable frontend primitives and canonical page-shell patterns so feature
work extends shared components instead of creating new local variants.
+Feature-owned warning cards, including notification delivery health, compose
+the shared `Button` variants for retry, dismiss, refresh, loading, and disabled
+states. Feature code owns the action copy and confirmation consequences, but
+must not recreate local button chrome for those controls.
The alert schedule's initial-delivery selector composes `SettingsPanel` and
`FormSelect`, uses the shared alert-configuration presentation vocabulary, and
exposes the same email, webhook, Apprise, and all-destination labels used by
diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md
index 534382a89..6446eb8e4 100644
--- a/docs/release-control/v6/internal/subsystems/notifications.md
+++ b/docs/release-control/v6/internal/subsystems/notifications.md
@@ -331,6 +331,15 @@ counts cannot be read. Pending retries do not degrade health. The response
must expose fixed reason codes and retention metadata rather than raw queue
errors or notification content.
+Operator recovery is owned by the queue, not by database-file deletion. The
+settings-write retry action returns all retained `failed` and `dlq` rows to
+`pending`, resets their queue-attempt counters to a fresh retry budget, keeps
+their operational links, and wakes the processor; it never rewrites existing
+per-attempt audit rows. The settings-write dismiss action transitions those
+same rows to `cancelled`, preserves both queue and audit history, and clears
+the active health warning. Both actions are transactional across the selected
+terminal set and report the number of rows actually transitioned.
+
### User-facing delivery log and honest test sends
The queue owner exposes its retained per-attempt audit rows to the local
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index 4b9d2746d..3c6431d20 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -201,6 +201,9 @@ active-alert read model only while detector evaluation is enabled. They must
not hide storage or recovery alerts when external notification delivery is
pending, paused, snoozed, unavailable, or destination-disabled; those delivery
states do not change the underlying storage evidence or active lifecycle.
+Likewise, retrying or dismissing retained notification failures changes only
+delivery-queue state and its warning. It does not alter storage health,
+protection posture, recovery-point evidence, or restore readiness.
## Extension Points
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index c94a0a7df..15119ede9 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,17 +1,24 @@
{
"version": 1,
- "base_sha": "af6d4825152580779c21a48bc13a702c15a4a982",
- "verified_at": "2026-08-21T05:48:24Z",
+ "base_sha": "434e1448ff70464bccec3e6ec46257d10da2162f",
+ "verified_at": "2026-08-23T08:04:16Z",
"result": "passed",
"changed_paths": [
- "frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx"
+ "frontend-modern/src/api/notifications.ts",
+ "frontend-modern/src/features/alerts/AlertDeliveryHealthCard.tsx",
+ "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx",
+ "frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts",
+ "frontend-modern/src/utils/alertDestinationsPresentation.ts"
],
"content_sha256": {
- "frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx": "afcb6b556a0bb21e19b492744a62f97513206c27f3e8d643a2c8862e2e43fe13"
+ "frontend-modern/src/api/notifications.ts": "df77323e526d5c528bd4fb7fcfdccb70b5823c5b0dd41525718067ffd441b0b5",
+ "frontend-modern/src/features/alerts/AlertDeliveryHealthCard.tsx": "0e9c53191da45f5c81f336c1034bc0e0651d88f92fc89b268d9a997dcd7d1b20",
+ "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "23cc5d7800028b35019359af2b46937b5fef308bdf82116240fa92c21e16177c",
+ "frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts": "6993829996d1acd877f310212f045f33ee9f92b56cb1fee62e293cf4c9b8f14e",
+ "frontend-modern/src/utils/alertDestinationsPresentation.ts": "52bf726d4b5a081db84c1be05bce466231749c73c0f2b43acce4cec11b7ff80b"
},
"routes": [
- "/settings/infrastructure",
- "/settings/infrastructure (Agent Doctor view via Open Agent Doctor)"
+ "/alerts/notifications"
],
"viewports": [
{
@@ -24,16 +31,15 @@
}
],
"states": [
- "Agent Doctor fleet table with 1 Critical and 12 Healthy targets (mock stack against live dev backend)",
- "Expanded Critical row prod-euw1-k8s-03 showing stale-report diagnostic, no credential repair offered",
- "Expanded Healthy row apollo-114 showing no fleet-health issues",
- "Doctor table re-rendered at mobile width after reload",
- "No repair_authentication target exists in the mock fleet, so the replaceCredential command branch was verified by unit tests (InfrastructureOperationsModel.test.tsx source pin plus agentInstallCommand builder tests), not by a rendered command"
+ "Healthy queue with no retained terminal deliveries: recovery actions correctly absent and delivery activity empty",
+ "Notifications-paused warning remains distinct from queue health and explains that test sends bypass the activation gate",
+ "Desktop and narrow layouts render the delivery activity card without horizontal overflow or console errors",
+ "Retained-failure warning, retry/dismiss controls, confirmation consequences, loading states, API refresh, and success/error paths verified by focused component, state, API, and queue tests because the live development queue had no terminal failures"
],
"interactions": [
- "Clicked Open Agent Doctor from Infrastructure settings",
- "Expanded the Critical target row prod-euw1-k8s-03",
- "Expanded the Healthy target row apollo-114",
- "Reloaded the doctor view at 375x812 and confirmed clean render with zero console errors"
+ "Navigated from Alerts overview to Notifications",
+ "Refreshed healthy delivery status on the desktop layout",
+ "Reloaded /alerts/notifications at 1280x800 and 375x812",
+ "Confirmed Recent delivery activity and Refresh delivery status remain visible and usable at narrow width"
]
}
diff --git a/frontend-modern/public/docs/API.md b/frontend-modern/public/docs/API.md
index 92c89dc3c..ca8aa29ca 100644
--- a/frontend-modern/public/docs/API.md
+++ b/frontend-modern/public/docs/API.md
@@ -600,6 +600,12 @@ Common reporting error codes:
- `GET /api/notifications/dlq` (admin)
- `POST /api/notifications/dlq/retry` (admin)
- `POST /api/notifications/dlq/delete` (admin)
+- `POST /api/notifications/terminal-failures/retry` (admin, `settings:write`)
+ - Returns every retained `failed` or `dlq` delivery to `pending` with a fresh
+ retry budget. Existing per-attempt delivery history is preserved.
+- `POST /api/notifications/terminal-failures/dismiss` (admin, `settings:write`)
+ - Marks every retained terminal delivery `cancelled`, clearing the active
+ queue-health warning without deleting delivery history.
---
diff --git a/frontend-modern/public/docs/TROUBLESHOOTING.md b/frontend-modern/public/docs/TROUBLESHOOTING.md
index a6445d150..195809004 100644
--- a/frontend-modern/public/docs/TROUBLESHOOTING.md
+++ b/frontend-modern/public/docs/TROUBLESHOOTING.md
@@ -151,6 +151,10 @@ first step before platform-local cleanup.
- Open **Alerts → Notifications** first. Pulse shows a delivery warning when
failed or dead-lettered notifications remain in the persistent queue; a
missing queue-health read is shown as unavailable rather than healthy.
+- After correcting the destination, use **Retry retained deliveries**. Use
+ **Dismiss retained failures** only when those deliveries should not be sent.
+ Both actions preserve delivery history; do not delete `notification_queue.db`
+ to clear the warning.
- Check SMTP settings in **Alerts → Notifications**.
- Check logs: `docker logs pulse | grep email`.
- Ensure your SMTP provider allows the connection (e.g., Gmail App Passwords).
@@ -159,6 +163,9 @@ first step before platform-local cleanup.
- Check the delivery warning in **Alerts → Notifications** and use **Send test**
after correcting the destination. Recoverable retries do not trigger the
warning; retained terminal failures do.
+- If the test succeeds, use **Retry retained deliveries** to give the retained
+ items a fresh retry budget. Dismiss them only when delivery is no longer
+ wanted; neither action deletes the audit trail.
- Verify the URL is reachable from the Pulse server.
- If targeting private IPs, allow them in **Settings → System → Network → Webhook Security**.
- Check Pulse logs for HTTP status codes and response bodies.
diff --git a/frontend-modern/src/api/__tests__/notifications.test.ts b/frontend-modern/src/api/__tests__/notifications.test.ts
index 4f45cbdc6..ddcce6ea0 100644
--- a/frontend-modern/src/api/__tests__/notifications.test.ts
+++ b/frontend-modern/src/api/__tests__/notifications.test.ts
@@ -373,6 +373,28 @@ describe('NotificationsAPI', () => {
expect(log).toEqual({ entries: [], windowDays: 7 });
});
+ it('retries retained terminal deliveries through the operator recovery endpoint', async () => {
+ apiFetchJSONMock.mockResolvedValueOnce({ affected: 3, success: true } as any);
+
+ const result = await NotificationsAPI.retryTerminalFailures();
+
+ expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/notifications/terminal-failures/retry', {
+ method: 'POST',
+ });
+ expect(result).toEqual({ affected: 3, success: true });
+ });
+
+ it('dismisses retained terminal failures and fails closed on malformed counts', async () => {
+ apiFetchJSONMock.mockResolvedValueOnce({ affected: -1, success: 'yes' } as any);
+
+ const result = await NotificationsAPI.dismissTerminalFailures();
+
+ expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/notifications/terminal-failures/dismiss', {
+ method: 'POST',
+ });
+ expect(result).toEqual({ affected: 0, success: false });
+ });
+
it('passes the deliveryPaused flag through from test-send responses', async () => {
apiFetchJSONMock.mockResolvedValueOnce({
status: 'success',
diff --git a/frontend-modern/src/api/notifications.ts b/frontend-modern/src/api/notifications.ts
index 2a8b168b2..5cba35225 100644
--- a/frontend-modern/src/api/notifications.ts
+++ b/frontend-modern/src/api/notifications.ts
@@ -129,6 +129,11 @@ export interface NotificationHealth {
queue: NotificationQueueHealth;
}
+export interface NotificationTerminalFailureActionResult {
+ success: boolean;
+ affected: number;
+}
+
export type NotificationDeliveryOutcome = 'sent' | 'retry' | 'failed' | 'dead_letter' | 'cancelled';
export interface NotificationDeliveryLogEntry {
@@ -352,6 +357,28 @@ export class NotificationsAPI {
};
}
+ static async retryTerminalFailures(): Promise {
+ const payload = await apiFetchJSON>(
+ `${this.baseUrl}/terminal-failures/retry`,
+ { method: 'POST' },
+ );
+ return {
+ success: strictBoolean(payload.success),
+ affected: nonNegativeCount(payload.affected) ?? 0,
+ };
+ }
+
+ static async dismissTerminalFailures(): Promise {
+ const payload = await apiFetchJSON>(
+ `${this.baseUrl}/terminal-failures/dismiss`,
+ { method: 'POST' },
+ );
+ return {
+ success: strictBoolean(payload.success),
+ affected: nonNegativeCount(payload.affected) ?? 0,
+ };
+ }
+
static async updateAppriseConfig(config: AppriseConfig): Promise {
return apiFetchJSON(`${this.baseUrl}/apprise`, {
method: 'PUT',
diff --git a/frontend-modern/src/features/alerts/AlertDeliveryHealthCard.test.tsx b/frontend-modern/src/features/alerts/AlertDeliveryHealthCard.test.tsx
index a08caafc0..c6d216792 100644
--- a/frontend-modern/src/features/alerts/AlertDeliveryHealthCard.test.tsx
+++ b/frontend-modern/src/features/alerts/AlertDeliveryHealthCard.test.tsx
@@ -38,12 +38,16 @@ describe('AlertDeliveryHealthCard', () => {
it('tells operators that retained terminal deliveries were not delivered', () => {
const onRefresh = vi.fn();
+ const onRetryFailures = vi.fn();
+ const onDismissFailures = vi.fn();
render(() => (
));
@@ -62,6 +66,12 @@ describe('AlertDeliveryHealthCard', () => {
fireEvent.click(screen.getByRole('button', { name: 'Refresh delivery status' }));
expect(onRefresh).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Retry retained deliveries' }));
+ expect(onRetryFailures).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss retained failures' }));
+ expect(onDismissFailures).toHaveBeenCalledTimes(1);
});
it('fails closed when queue health cannot be verified', () => {
diff --git a/frontend-modern/src/features/alerts/AlertDeliveryHealthCard.tsx b/frontend-modern/src/features/alerts/AlertDeliveryHealthCard.tsx
index 3597a57fb..20210b9c1 100644
--- a/frontend-modern/src/features/alerts/AlertDeliveryHealthCard.tsx
+++ b/frontend-modern/src/features/alerts/AlertDeliveryHealthCard.tsx
@@ -2,11 +2,14 @@ import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
import type { NotificationQueueHealth } from '@/api/notifications';
+import { Button } from '@/components/shared/Button';
import { Card } from '@/components/shared/Card';
import {
+ getAlertDestinationsDeliveryDismissLabel,
getAlertDestinationsDeliveryHealthDescription,
getAlertDestinationsDeliveryHealthTitle,
getAlertDestinationsDeliveryRefreshLabel,
+ getAlertDestinationsDeliveryRetryLabel,
} from '@/utils/alertDestinationsPresentation';
interface AlertDeliveryHealthCardProps {
@@ -14,6 +17,10 @@ interface AlertDeliveryHealthCardProps {
unavailable: boolean;
refreshing: boolean;
onRefresh: () => void;
+ retryingFailures?: boolean;
+ dismissingFailures?: boolean;
+ onRetryFailures?: () => void;
+ onDismissFailures?: () => void;
}
export function AlertDeliveryHealthCard(props: AlertDeliveryHealthCardProps) {
@@ -46,15 +53,39 @@ export function AlertDeliveryHealthCard(props: AlertDeliveryHealthCardProps) {
-
-
- {getAlertDestinationsDeliveryRefreshLabel()}
-
+
+ {props.onRetryFailures ? (
+
+ {getAlertDestinationsDeliveryRetryLabel()}
+
+ ) : null}
+ {props.onDismissFailures ? (
+
+ {getAlertDestinationsDeliveryDismissLabel()}
+
+ ) : null}
+
+
+ {getAlertDestinationsDeliveryRefreshLabel()}
+
+
);
diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx
index 0f5f3bf84..b1bd0729e 100644
--- a/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx
+++ b/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx
@@ -16,6 +16,8 @@ vi.mock('@/api/notifications', () => ({
getDeliveryLog: vi.fn(),
getHealth: vi.fn(),
getWebhooks: vi.fn(),
+ dismissTerminalFailures: vi.fn(),
+ retryTerminalFailures: vi.fn(),
testNotification: vi.fn(),
testWebhook: vi.fn(),
updateWebhook: vi.fn(),
@@ -77,6 +79,8 @@ describe('useAlertDestinationsTabState', () => {
vi.mocked(NotificationsAPI.getDeliveryLog).mockReset();
vi.mocked(NotificationsAPI.getHealth).mockReset();
vi.mocked(NotificationsAPI.getWebhooks).mockReset();
+ vi.mocked(NotificationsAPI.dismissTerminalFailures).mockReset();
+ vi.mocked(NotificationsAPI.retryTerminalFailures).mockReset();
vi.mocked(NotificationsAPI.testNotification).mockReset();
vi.mocked(NotificationsAPI.testWebhook).mockReset();
vi.mocked(NotificationsAPI.updateWebhook).mockReset();
@@ -248,4 +252,82 @@ describe('useAlertDestinationsTabState', () => {
expect(notificationStore.warning).toHaveBeenCalledTimes(2);
expect(notificationStore.success).not.toHaveBeenCalled();
});
+
+ it('confirms and resolves retained terminal deliveries without deleting delivery history', async () => {
+ const [emailConfig] = createSignal(buildEmailConfig());
+ const [appriseConfig, setAppriseConfig] = createSignal(buildAppriseConfig());
+ const [configLoadError] = createSignal(null);
+ const [isRetrying] = createSignal(false);
+ const [isLoadingDestinations] = createSignal(false);
+ const confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(true);
+
+ vi.mocked(NotificationsAPI.getWebhooks).mockResolvedValue([]);
+ vi.mocked(NotificationsAPI.getHealth).mockResolvedValue({
+ overallHealthy: false,
+ queue: {
+ attentionRequired: 3,
+ completedRetentionDays: 7,
+ countsAreRetentionBounded: true,
+ deadLetter: 2,
+ deadLetterRetentionDays: 30,
+ failed: 1,
+ failureClasses7d: {
+ authentication: 3,
+ configuration: 0,
+ connectivity: 0,
+ rate_limited: 0,
+ rejected: 0,
+ tls: 0,
+ unknown: 0,
+ },
+ failureClassesAvailable: true,
+ failureClassWindowDays: 7,
+ healthy: false,
+ pending: 0,
+ reasonCodes: ['retained_failed_deliveries', 'retained_dead_letter_deliveries'],
+ retryAttemptsAffectHealth: false,
+ sending: 0,
+ sent: 0,
+ status: 'degraded',
+ terminalFailuresAffectHealth: true,
+ },
+ });
+ vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue({ entries: [], windowDays: 7 });
+ vi.mocked(NotificationsAPI.retryTerminalFailures).mockResolvedValue({
+ affected: 3,
+ success: true,
+ });
+ vi.mocked(NotificationsAPI.dismissTerminalFailures).mockResolvedValue({
+ affected: 3,
+ success: true,
+ });
+
+ const { result } = renderHook(() =>
+ useAlertDestinationsTabState({
+ appriseConfig,
+ configLoadError,
+ emailConfig,
+ isLoadingDestinations,
+ isRetrying,
+ onRetryLoad: vi.fn(),
+ setAppriseConfig,
+ }),
+ );
+
+ await waitFor(() => expect(result.deliveryNeedsAttention()).toBe(true));
+ await result.retryTerminalFailures();
+ expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('Retry 3 retained deliveries'));
+ expect(NotificationsAPI.retryTerminalFailures).toHaveBeenCalledTimes(1);
+ expect(notificationStore.success).toHaveBeenCalledWith(
+ '3 retained deliveries queued for retry.',
+ );
+
+ await result.dismissTerminalFailures();
+ expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('Dismiss 3 retained failures'));
+ expect(NotificationsAPI.dismissTerminalFailures).toHaveBeenCalledTimes(1);
+ expect(notificationStore.success).toHaveBeenCalledWith('3 retained failures dismissed.');
+ expect(NotificationsAPI.getDeliveryLog).toHaveBeenCalledTimes(3);
+
+ confirmSpy.mockRestore();
+ });
});
diff --git a/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx b/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx
index 2b89fcea0..84099afc9 100644
--- a/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx
+++ b/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx
@@ -80,6 +80,10 @@ export function DestinationsTab(props: DestinationsTabProps) {
unavailable={state.deliveryHealthUnavailable()}
refreshing={state.refreshingDeliveryHealth()}
onRefresh={() => void state.loadDeliveryHealth()}
+ retryingFailures={state.retryingTerminalFailures()}
+ dismissingFailures={state.dismissingTerminalFailures()}
+ onRetryFailures={() => void state.retryTerminalFailures()}
+ onDismissFailures={() => void state.dismissTerminalFailures()}
/>
diff --git a/frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts b/frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts
index 692a7ed3d..c33edba3b 100644
--- a/frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts
+++ b/frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts
@@ -8,6 +8,8 @@ import {
getAlertDestinationsAppriseTestFailure,
getAlertDestinationsAppriseTestSuccess,
getAlertDestinationsAppriseValidationError,
+ getAlertDestinationsDeliveryDismissConfirmation,
+ getAlertDestinationsDeliveryRetryConfirmation,
getAlertDestinationsEmailTestFailure,
getAlertDestinationsEmailTestSuccess,
getAlertDestinationsTestPausedWarning,
@@ -32,6 +34,8 @@ export interface AlertDestinationsTabStateProps {
export function useAlertDestinationsTabState(props: AlertDestinationsTabStateProps) {
const [testingEmail, setTestingEmail] = createSignal(false);
const [testingApprise, setTestingApprise] = createSignal(false);
+ const [retryingTerminalFailures, setRetryingTerminalFailures] = createSignal(false);
+ const [dismissingTerminalFailures, setDismissingTerminalFailures] = createSignal(false);
const {
deliveryHealth,
deliveryHealthUnavailable,
@@ -138,6 +142,46 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
void loadDeliveryLog();
};
+ const retryTerminalFailures = async () => {
+ const count = deliveryHealth()?.queue.attentionRequired ?? 0;
+ if (count <= 0 || !confirm(getAlertDestinationsDeliveryRetryConfirmation(count))) {
+ return;
+ }
+ setRetryingTerminalFailures(true);
+ try {
+ const result = await NotificationsAPI.retryTerminalFailures();
+ notificationStore.success(
+ `${result.affected} retained ${result.affected === 1 ? 'delivery' : 'deliveries'} queued for retry.`,
+ );
+ await Promise.all([loadDeliveryHealth(), loadDeliveryLog()]);
+ } catch (error) {
+ logger.error('Failed to retry retained notification deliveries', error);
+ notificationStore.error('Unable to retry retained notification deliveries.');
+ } finally {
+ setRetryingTerminalFailures(false);
+ }
+ };
+
+ const dismissTerminalFailures = async () => {
+ const count = deliveryHealth()?.queue.attentionRequired ?? 0;
+ if (count <= 0 || !confirm(getAlertDestinationsDeliveryDismissConfirmation(count))) {
+ return;
+ }
+ setDismissingTerminalFailures(true);
+ try {
+ const result = await NotificationsAPI.dismissTerminalFailures();
+ notificationStore.success(
+ `${result.affected} retained ${result.affected === 1 ? 'failure' : 'failures'} dismissed.`,
+ );
+ await Promise.all([loadDeliveryHealth(), loadDeliveryLog()]);
+ } catch (error) {
+ logger.error('Failed to dismiss retained notification failures', error);
+ notificationStore.error('Unable to dismiss retained notification failures.');
+ } finally {
+ setDismissingTerminalFailures(false);
+ }
+ };
+
onMount(() => {
void loadDeliveryHealth();
void loadDeliveryLog();
@@ -150,6 +194,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
deliveryLog,
deliveryLogUnavailable,
deliveryNeedsAttention,
+ dismissTerminalFailures,
+ dismissingTerminalFailures,
handleRetry,
hasLoadError,
isLoading,
@@ -157,6 +203,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
loadDeliveryLog,
refreshingDeliveryHealth,
refreshingDeliveryLog,
+ retryTerminalFailures,
+ retryingTerminalFailures,
testApprise,
testEmailConfig,
testingApprise,
diff --git a/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts b/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts
index e24bd5db4..2e84db315 100644
--- a/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts
+++ b/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts
@@ -42,7 +42,11 @@ import {
getAlertDestinationsConfigLoadError,
getAlertDestinationsDeliveryHealthDescription,
getAlertDestinationsDeliveryHealthTitle,
+ getAlertDestinationsDeliveryDismissConfirmation,
+ getAlertDestinationsDeliveryDismissLabel,
getAlertDestinationsDeliveryRefreshLabel,
+ getAlertDestinationsDeliveryRetryConfirmation,
+ getAlertDestinationsDeliveryRetryLabel,
getAlertDestinationsEmailTestFailure,
getAlertDestinationsEmailTestSuccess,
getAlertDestinationsLoadErrorBanner,
@@ -170,6 +174,14 @@ describe('alertDestinationsPresentation', () => {
}),
).toContain('could not verify the notification queue');
expect(getAlertDestinationsDeliveryRefreshLabel()).toBe('Refresh delivery status');
+ expect(getAlertDestinationsDeliveryRetryLabel()).toBe('Retry retained deliveries');
+ expect(getAlertDestinationsDeliveryDismissLabel()).toBe('Dismiss retained failures');
+ expect(getAlertDestinationsDeliveryRetryConfirmation(1)).toContain(
+ 'A destination that accepted an earlier attempt may receive a duplicate',
+ );
+ expect(getAlertDestinationsDeliveryDismissConfirmation(2)).toContain(
+ 'Delivery history remains available',
+ );
});
});
diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts
index 729d12854..e1e9320fd 100644
--- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts
+++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts
@@ -153,6 +153,20 @@ describe('docsLinks', () => {
expect(importContract).toContain('settings:write');
});
+ it('ships the retained notification recovery API contract', () => {
+ const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
+ const shippedAPIReference = readFileSync(
+ path.join(frontendRoot, 'public', 'docs', 'API.md'),
+ 'utf8',
+ );
+
+ expect(shippedAPIReference).toBe(apiReference);
+ expect(apiReference).toContain('`POST /api/notifications/terminal-failures/retry`');
+ expect(apiReference).toContain('`POST /api/notifications/terminal-failures/dismiss`');
+ expect(apiReference).toContain('Existing per-attempt delivery history is preserved');
+ expect(apiReference).toContain('without deleting delivery history');
+ });
+
it('ships the truthful Patrol objective API contract', () => {
const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
const shippedAPIReference = readFileSync(
diff --git a/frontend-modern/src/utils/alertDestinationsPresentation.ts b/frontend-modern/src/utils/alertDestinationsPresentation.ts
index 7b1b5efa3..819832052 100644
--- a/frontend-modern/src/utils/alertDestinationsPresentation.ts
+++ b/frontend-modern/src/utils/alertDestinationsPresentation.ts
@@ -79,6 +79,8 @@ export const ALERT_DESTINATIONS_DELIVERY_DEGRADED_TITLE = 'Notification delivery
export const ALERT_DESTINATIONS_DELIVERY_UNAVAILABLE_TITLE =
'Notification delivery status is unavailable';
export const ALERT_DESTINATIONS_DELIVERY_REFRESH_LABEL = 'Refresh delivery status';
+export const ALERT_DESTINATIONS_DELIVERY_RETRY_LABEL = 'Retry retained deliveries';
+export const ALERT_DESTINATIONS_DELIVERY_DISMISS_LABEL = 'Dismiss retained failures';
export function getAlertDestinationsConfigLoadError() {
return ALERT_DESTINATIONS_CONFIG_LOAD_ERROR;
@@ -199,6 +201,22 @@ export function getAlertDestinationsDeliveryRefreshLabel() {
return ALERT_DESTINATIONS_DELIVERY_REFRESH_LABEL;
}
+export function getAlertDestinationsDeliveryRetryLabel() {
+ return ALERT_DESTINATIONS_DELIVERY_RETRY_LABEL;
+}
+
+export function getAlertDestinationsDeliveryDismissLabel() {
+ return ALERT_DESTINATIONS_DELIVERY_DISMISS_LABEL;
+}
+
+export function getAlertDestinationsDeliveryRetryConfirmation(count: number) {
+ return `Retry ${count} retained ${count === 1 ? 'delivery' : 'deliveries'} now? A destination that accepted an earlier attempt may receive a duplicate.`;
+}
+
+export function getAlertDestinationsDeliveryDismissConfirmation(count: number) {
+ return `Dismiss ${count} retained ${count === 1 ? 'failure' : 'failures'}? Pulse will clear the warning and will not retry them. Delivery history remains available.`;
+}
+
// Shown instead of the plain test-success toast when the backend reports the
// test went out while real alert delivery is paused. Without this, a
// successful test is exactly how installs come to believe delivery works
diff --git a/internal/api/alerting/notification_queue.go b/internal/api/alerting/notification_queue.go
index c1a7e53b1..ed085f77e 100644
--- a/internal/api/alerting/notification_queue.go
+++ b/internal/api/alerting/notification_queue.go
@@ -82,7 +82,7 @@ func (h *NotificationQueueHandlers) GetQueueStats(w http.ResponseWriter, r *http
// RetryDLQItem retries a specific notification from the DLQ
func (h *NotificationQueueHandlers) RetryDLQItem(w http.ResponseWriter, r *http.Request) {
- if !apihttp.EnsureScope(w, r, config.ScopeMonitoringWrite) {
+ if !apihttp.EnsureScope(w, r, config.ScopeSettingsWrite) {
return
}
@@ -129,7 +129,7 @@ func (h *NotificationQueueHandlers) RetryDLQItem(w http.ResponseWriter, r *http.
// DeleteDLQItem removes a notification from the DLQ permanently
func (h *NotificationQueueHandlers) DeleteDLQItem(w http.ResponseWriter, r *http.Request) {
- if !apihttp.EnsureScope(w, r, config.ScopeMonitoringWrite) {
+ if !apihttp.EnsureScope(w, r, config.ScopeSettingsWrite) {
return
}
@@ -174,6 +174,56 @@ func (h *NotificationQueueHandlers) DeleteDLQItem(w http.ResponseWriter, r *http
}
}
+// RetryTerminalFailures returns all retained terminal failures to the queue
+// after an operator has repaired the destination.
+func (h *NotificationQueueHandlers) RetryTerminalFailures(w http.ResponseWriter, r *http.Request) {
+ if !apihttp.EnsureScope(w, r, config.ScopeSettingsWrite) {
+ return
+ }
+ queue := h.monitor.GetNotificationManager().GetQueue()
+ if queue == nil {
+ http.Error(w, "Notification queue not initialized", http.StatusServiceUnavailable)
+ return
+ }
+ affected, err := queue.RetryTerminalFailures()
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to retry retained terminal notifications")
+ http.Error(w, "Failed to retry retained notification failures", http.StatusInternalServerError)
+ return
+ }
+ if err := utils.WriteJSONResponse(w, map[string]interface{}{
+ "success": true,
+ "affected": affected,
+ }); err != nil {
+ log.Error().Err(err).Msg("Failed to write terminal retry response")
+ }
+}
+
+// DismissTerminalFailures cancels all retained terminal queue rows without
+// deleting their immutable delivery audit.
+func (h *NotificationQueueHandlers) DismissTerminalFailures(w http.ResponseWriter, r *http.Request) {
+ if !apihttp.EnsureScope(w, r, config.ScopeSettingsWrite) {
+ return
+ }
+ queue := h.monitor.GetNotificationManager().GetQueue()
+ if queue == nil {
+ http.Error(w, "Notification queue not initialized", http.StatusServiceUnavailable)
+ return
+ }
+ affected, err := queue.DismissTerminalFailures()
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to dismiss retained terminal notifications")
+ http.Error(w, "Failed to dismiss retained notification failures", http.StatusInternalServerError)
+ return
+ }
+ if err := utils.WriteJSONResponse(w, map[string]interface{}{
+ "success": true,
+ "affected": affected,
+ }); err != nil {
+ log.Error().Err(err).Msg("Failed to write terminal dismissal response")
+ }
+}
+
// HandleNotificationQueue routes notification queue requests
func (h *NotificationQueueHandlers) HandleNotificationQueue(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
@@ -187,6 +237,10 @@ func (h *NotificationQueueHandlers) HandleNotificationQueue(w http.ResponseWrite
h.RetryDLQItem(w, r)
case path == "/api/notifications/dlq/delete" && r.Method == http.MethodPost:
h.DeleteDLQItem(w, r)
+ case path == "/api/notifications/terminal-failures/retry" && r.Method == http.MethodPost:
+ h.RetryTerminalFailures(w, r)
+ case path == "/api/notifications/terminal-failures/dismiss" && r.Method == http.MethodPost:
+ h.DismissTerminalFailures(w, r)
default:
http.Error(w, "Not found", http.StatusNotFound)
}
diff --git a/internal/api/alerting/notification_queue_additional_test.go b/internal/api/alerting/notification_queue_additional_test.go
index 4d9cbbd02..bf1ea67f3 100644
--- a/internal/api/alerting/notification_queue_additional_test.go
+++ b/internal/api/alerting/notification_queue_additional_test.go
@@ -116,6 +116,43 @@ func TestNotificationQueueHandlers_RetryAndDelete(t *testing.T) {
}
}
+func TestNotificationQueueHandlers_BulkTerminalFailureRecovery(t *testing.T) {
+ handler, queue := newNotificationQueueHandlers(t)
+ enqueueDLQNotification(t, queue, "notif-bulk")
+
+ req := httptest.NewRequest(http.MethodPost, "/api/notifications/terminal-failures/retry", nil)
+ rec := httptest.NewRecorder()
+ handler.RetryTerminalFailures(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("RetryTerminalFailures status = %d, want 200: %s", rec.Code, rec.Body.String())
+ }
+ var response struct {
+ Affected int `json:"affected"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
+ t.Fatalf("decode retry response: %v", err)
+ }
+ if response.Affected != 1 {
+ t.Fatalf("retry affected = %d, want 1", response.Affected)
+ }
+
+ if err := queue.UpdateStatus("notif-bulk", notifications.QueueStatusDLQ, "still unavailable"); err != nil {
+ t.Fatalf("mark retried notification DLQ: %v", err)
+ }
+ req = httptest.NewRequest(http.MethodPost, "/api/notifications/terminal-failures/dismiss", nil)
+ rec = httptest.NewRecorder()
+ handler.DismissTerminalFailures(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("DismissTerminalFailures status = %d, want 200: %s", rec.Code, rec.Body.String())
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
+ t.Fatalf("decode dismiss response: %v", err)
+ }
+ if response.Affected != 1 {
+ t.Fatalf("dismiss affected = %d, want 1", response.Affected)
+ }
+}
+
func TestNotificationQueueHandlers_HandleNotificationQueue(t *testing.T) {
handler, queue := newNotificationQueueHandlers(t)
enqueueDLQNotification(t, queue, "notif-3")
diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go
index 6db491fb4..7e5143c6e 100644
--- a/internal/api/route_inventory_test.go
+++ b/internal/api/route_inventory_test.go
@@ -594,6 +594,8 @@ var allRouteAllowlist = []string{
"/api/notifications/queue/stats",
"/api/notifications/dlq/retry",
"/api/notifications/dlq/delete",
+ "/api/notifications/terminal-failures/retry",
+ "/api/notifications/terminal-failures/dismiss",
"/api/system/settings",
"/api/system/settings/update",
"/api/system/ssh-config",
diff --git a/internal/api/router_routes_monitoring.go b/internal/api/router_routes_monitoring.go
index 7b5722405..d59276484 100644
--- a/internal/api/router_routes_monitoring.go
+++ b/internal/api/router_routes_monitoring.go
@@ -335,6 +335,20 @@ func (r *Router) registerMonitoringResourceRoutes(
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})))
+ r.mux.HandleFunc("/api/notifications/terminal-failures/retry", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, func(w http.ResponseWriter, req *http.Request) {
+ if req.Method == http.MethodPost {
+ r.notificationQueueHandlers.RetryTerminalFailures(w, req)
+ } else {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+ })))
+ r.mux.HandleFunc("/api/notifications/terminal-failures/dismiss", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, func(w http.ResponseWriter, req *http.Request) {
+ if req.Method == http.MethodPost {
+ r.notificationQueueHandlers.DismissTerminalFailures(w, req)
+ } else {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+ })))
// AI-powered infrastructure discovery endpoints
r.mux.HandleFunc("/api/discovery", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleListDiscoveries)))
r.mux.HandleFunc("/api/discovery/status", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleGetStatus)))
diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go
index b5054cb59..4376f226b 100644
--- a/internal/api/security_regression_test.go
+++ b/internal/api/security_regression_test.go
@@ -508,6 +508,8 @@ func TestNotificationsDLQMutationsRequireSettingsWriteScope(t *testing.T) {
cases := []string{
"/api/notifications/dlq/retry",
"/api/notifications/dlq/delete",
+ "/api/notifications/terminal-failures/retry",
+ "/api/notifications/terminal-failures/dismiss",
}
for _, path := range cases {
@@ -2897,6 +2899,8 @@ func TestProxyAuthNonAdminDeniedAdminEndpoints(t *testing.T) {
{method: http.MethodGet, path: "/api/notifications/dlq", body: ""},
{method: http.MethodPost, path: "/api/notifications/dlq/retry", body: `{}`},
{method: http.MethodPost, path: "/api/notifications/dlq/delete", body: `{}`},
+ {method: http.MethodPost, path: "/api/notifications/terminal-failures/retry", body: `{}`},
+ {method: http.MethodPost, path: "/api/notifications/terminal-failures/dismiss", body: `{}`},
}
for _, tc := range cases {
diff --git a/internal/notifications/queue.go b/internal/notifications/queue.go
index d5816bd02..26496544f 100644
--- a/internal/notifications/queue.go
+++ b/internal/notifications/queue.go
@@ -1278,6 +1278,122 @@ func (nq *NotificationQueue) GetDLQ(limit int) ([]*QueuedNotification, error) {
return notifications, rows.Err()
}
+// RetryTerminalFailures returns every retained failed or dead-lettered
+// delivery to the pending queue with a fresh retry budget. The per-attempt
+// audit rows remain intact, so this operator recovery never rewrites delivery
+// history or makes an earlier failure look successful.
+func (nq *NotificationQueue) RetryTerminalFailures() (int, error) {
+ return nq.resolveTerminalFailures(true)
+}
+
+// DismissTerminalFailures marks every retained failed or dead-lettered
+// delivery as cancelled. This clears the active queue-health warning while
+// retaining the immutable delivery audit instead of asking operators to
+// delete the queue database.
+func (nq *NotificationQueue) DismissTerminalFailures() (int, error) {
+ return nq.resolveTerminalFailures(false)
+}
+
+func (nq *NotificationQueue) resolveTerminalFailures(retry bool) (int, error) {
+ if nq == nil || nq.db == nil {
+ return 0, fmt.Errorf("notification queue not initialized")
+ }
+
+ nq.mu.Lock()
+ defer nq.mu.Unlock()
+
+ rows, err := nq.db.Query(`
+ SELECT id, operational_links
+ FROM notification_queue
+ WHERE status IN ('failed', 'dlq')
+ ORDER BY completed_at, id
+ `)
+ if err != nil {
+ return 0, fmt.Errorf("read retained terminal notifications: %w", err)
+ }
+ type terminalNotification struct {
+ id string
+ links []operationaltrust.NotificationLink
+ }
+ terminal := make([]terminalNotification, 0)
+ for rows.Next() {
+ var id, rawLinks string
+ if err := rows.Scan(&id, &rawLinks); err != nil {
+ _ = rows.Close()
+ return 0, fmt.Errorf("scan retained terminal notification: %w", err)
+ }
+ var links []operationaltrust.NotificationLink
+ if err := json.Unmarshal([]byte(rawLinks), &links); err != nil {
+ _ = rows.Close()
+ return 0, fmt.Errorf("decode retained terminal notification links: %w", err)
+ }
+ terminal = append(terminal, terminalNotification{id: id, links: links})
+ }
+ if err := rows.Close(); err != nil {
+ return 0, fmt.Errorf("close retained terminal notification rows: %w", err)
+ }
+ if err := rows.Err(); err != nil {
+ return 0, fmt.Errorf("iterate retained terminal notifications: %w", err)
+ }
+ if len(terminal) == 0 {
+ return 0, nil
+ }
+
+ tx, err := nq.db.Begin()
+ if err != nil {
+ return 0, fmt.Errorf("begin terminal notification recovery: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ now := time.Now()
+ affected := 0
+ for _, notification := range terminal {
+ state := operationaltrust.NotificationCancelled
+ if retry {
+ state = operationaltrust.NotificationRetrying
+ }
+ linksJSON, err := json.Marshal(transitionNotificationLinks(notification.links, state, now))
+ if err != nil {
+ return 0, fmt.Errorf("encode terminal notification links: %w", err)
+ }
+
+ var result sql.Result
+ if retry {
+ result, err = tx.Exec(`
+ UPDATE notification_queue
+ SET status = 'pending', attempts = 0, last_attempt = NULL,
+ last_error = NULL, next_retry_at = ?, completed_at = NULL,
+ operational_links = ?
+ WHERE id = ? AND status IN ('failed', 'dlq')
+ `, now.Unix(), string(linksJSON), notification.id)
+ } else {
+ result, err = tx.Exec(`
+ UPDATE notification_queue
+ SET status = 'cancelled', last_attempt = ?,
+ last_error = 'Dismissed by operator', next_retry_at = NULL,
+ completed_at = ?, operational_links = ?
+ WHERE id = ? AND status IN ('failed', 'dlq')
+ `, now.Unix(), now.Unix(), string(linksJSON), notification.id)
+ }
+ if err != nil {
+ return 0, fmt.Errorf("resolve terminal notification %s: %w", notification.id, err)
+ }
+ if count, countErr := result.RowsAffected(); countErr == nil {
+ affected += int(count)
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return 0, fmt.Errorf("commit terminal notification recovery: %w", err)
+ }
+ if retry && affected > 0 {
+ select {
+ case nq.notifyChan <- struct{}{}:
+ default:
+ }
+ }
+ return affected, nil
+}
+
// RecordAudit records a notification delivery attempt in the audit log
func (nq *NotificationQueue) RecordAudit(notif *QueuedNotification, success bool, errorMsg string) error {
nq.mu.Lock()
diff --git a/internal/notifications/queue_test.go b/internal/notifications/queue_test.go
index 79eaf337d..5fbfea700 100644
--- a/internal/notifications/queue_test.go
+++ b/internal/notifications/queue_test.go
@@ -1381,6 +1381,73 @@ func TestGetQueueStats(t *testing.T) {
}
})
+ t.Run("operator recovery retries or dismisses every terminal row without deleting history", func(t *testing.T) {
+ nq, err := NewNotificationQueue(t.TempDir())
+ if err != nil {
+ t.Fatalf("NewNotificationQueue: %v", err)
+ }
+ defer func() { _ = nq.Stop() }()
+
+ for _, notif := range []*QueuedNotification{
+ {ID: "failed-recovery", Type: "email", Status: QueueStatusPending, Attempts: 8, MaxAttempts: 8, Config: []byte(`{}`)},
+ {ID: "dlq-recovery", Type: "webhook", Status: QueueStatusPending, Attempts: 8, MaxAttempts: 8, Config: []byte(`{}`)},
+ {ID: "pending-unrelated", Type: "email", Status: QueueStatusPending, MaxAttempts: 8, Config: []byte(`{}`)},
+ } {
+ if err := nq.Enqueue(notif); err != nil {
+ t.Fatalf("enqueue %s: %v", notif.ID, err)
+ }
+ }
+ if err := nq.UpdateStatus("failed-recovery", QueueStatusFailed, "authentication failed"); err != nil {
+ t.Fatalf("mark failed: %v", err)
+ }
+ if err := nq.UpdateStatus("dlq-recovery", QueueStatusDLQ, "retries exhausted"); err != nil {
+ t.Fatalf("mark dlq: %v", err)
+ }
+
+ affected, err := nq.RetryTerminalFailures()
+ if err != nil {
+ t.Fatalf("RetryTerminalFailures: %v", err)
+ }
+ if affected != 2 {
+ t.Fatalf("retry affected = %d, want 2", affected)
+ }
+ for _, id := range []string{"failed-recovery", "dlq-recovery"} {
+ var status string
+ var attempts int
+ var lastAttempt, lastError, completedAt *int64
+ if err := nq.db.QueryRow(`
+ SELECT status, attempts, last_attempt, last_error, completed_at
+ FROM notification_queue WHERE id = ?
+ `, id).Scan(&status, &attempts, &lastAttempt, &lastError, &completedAt); err != nil {
+ t.Fatalf("read retried %s: %v", id, err)
+ }
+ if status != string(QueueStatusPending) || attempts != 0 || lastAttempt != nil || lastError != nil || completedAt != nil {
+ t.Fatalf("retried %s = status %q attempts %d lastAttempt %v lastError %v completedAt %v", id, status, attempts, lastAttempt, lastError, completedAt)
+ }
+ }
+
+ if err := nq.UpdateStatus("failed-recovery", QueueStatusFailed, "still invalid"); err != nil {
+ t.Fatalf("mark failed again: %v", err)
+ }
+ if err := nq.UpdateStatus("dlq-recovery", QueueStatusDLQ, "still unavailable"); err != nil {
+ t.Fatalf("mark dlq again: %v", err)
+ }
+ affected, err = nq.DismissTerminalFailures()
+ if err != nil {
+ t.Fatalf("DismissTerminalFailures: %v", err)
+ }
+ if affected != 2 {
+ t.Fatalf("dismiss affected = %d, want 2", affected)
+ }
+ stats, err := nq.GetQueueStats()
+ if err != nil {
+ t.Fatalf("GetQueueStats: %v", err)
+ }
+ if stats[string(QueueStatusFailed)] != 0 || stats[string(QueueStatusDLQ)] != 0 || stats[string(QueueStatusCancelled)] != 2 || stats[string(QueueStatusPending)] != 1 {
+ t.Fatalf("stats after dismissal = %#v", stats)
+ }
+ })
+
t.Run("UpdateStatus returns error for non-existent notification", func(t *testing.T) {
tempDir := t.TempDir()
nq, err := NewNotificationQueue(tempDir)